From c01b562ddde37c18ac5e2a92b71c3de54d3b7fdf Mon Sep 17 00:00:00 2001 From: Nick Santana <nick@mobilecoin.com> Date: Wed, 3 May 2023 08:59:42 -0700 Subject: [PATCH] Add RFC5380 `DistinguishedName` behavior RFC5280 section [4.2.1.4](https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.4), calls out specific behavior on how issuer and subject names should be compared. It calls these fields `DistinguishedName`. This change implements most of this behavior with the exception of prohibited unicode code points and ignoring bidi code points. --- verifier/Cargo.toml | 4 +- verifier/src/x509.rs | 2 + verifier/src/x509/certs.rs | 5 + verifier/src/x509/chain.rs | 21 +- verifier/src/x509/error.rs | 2 + verifier/src/x509/name.rs | 391 +++++++++++++++++++++++++++++++++++ verifier/src/x509/rfc4518.rs | 153 ++++++++++++++ 7 files changed, 574 insertions(+), 4 deletions(-) create mode 100644 verifier/src/x509/name.rs create mode 100644 verifier/src/x509/rfc4518.rs diff --git a/verifier/Cargo.toml b/verifier/Cargo.toml index fef4c20..f1169db 100644 --- a/verifier/Cargo.toml +++ b/verifier/Cargo.toml @@ -14,9 +14,10 @@ repository = { workspace = true } rust-version = { workspace = true } [features] -alloc = ["pem-rfc7468/alloc", "dep:const-oid", "dep:p256", "dep:x509-cert", "dep:rsa"] +alloc = ["pem-rfc7468/alloc", "dep:const-oid", "dep:p256", "dep:x509-cert", "dep:rsa", "dep:caseless", "dep:unicode-normalization"] [dependencies] +caseless = { version = "0.2.1", default-features = false, optional = true } const-oid = { version = "0.9.2", default-features = false, optional = true } displaydoc = { version = "0.2.1", default-features = false } mc-sgx-core-types = "0.6.0" @@ -24,6 +25,7 @@ p256 = { version = "0.13.0", default-features = false, features = ["ecdsa"], opt pem-rfc7468 = { version = "0.7.0", default-features = false, optional = true } rsa = { version = "0.9.0", default-features = false, features = ["sha2"], optional = true } subtle = { version = "2.4.0", default-features = false } +unicode-normalization = { version = "0.1.22", default-features = false, optional = true } x509-cert = { version = "0.2.0", default-features = false, optional = true } [dev-dependencies] diff --git a/verifier/src/x509.rs b/verifier/src/x509.rs index b0032ed..f607985 100644 --- a/verifier/src/x509.rs +++ b/verifier/src/x509.rs @@ -8,6 +8,8 @@ mod certs; mod chain; mod crl; mod error; +mod name; +mod rfc4518; pub use algorithm::{PublicKey, Signature}; pub use certs::{UnverifiedCertificate, VerifiedCertificate}; diff --git a/verifier/src/x509/certs.rs b/verifier/src/x509/certs.rs index 0f925b0..cf7532b 100644 --- a/verifier/src/x509/certs.rs +++ b/verifier/src/x509/certs.rs @@ -155,6 +155,11 @@ impl VerifiedCertificate { &self.key } + /// Get the issuer of the certificate + pub fn issuer(&self) -> &Name { + &self.certificate.tbs_certificate.issuer + } + /// Get the subject name of the certificate pub fn subject_name(&self) -> &Name { &self.certificate.tbs_certificate.subject diff --git a/verifier/src/x509/chain.rs b/verifier/src/x509/chain.rs index 0f06271..17e22d2 100644 --- a/verifier/src/x509/chain.rs +++ b/verifier/src/x509/chain.rs @@ -9,6 +9,7 @@ use super::{Error, Result}; use crate::x509::algorithm::PublicKey; use crate::x509::certs::VerifiedCertificate; use crate::x509::crl::UnverifiedCrl; +use crate::x509::name::DistinguishedName; use alloc::vec::Vec; use core::time::Duration; @@ -56,6 +57,7 @@ impl CertificateChain { for cert in &self.certificates { let key = signing_cert.public_key(); let verified_cert = cert.verify(key, unix_time)?; + verify_name_chain(&verified_cert, &signing_cert)?; verify_certificate_not_revoked(&verified_cert, &signing_cert, crls, unix_time)?; signing_cert = verified_cert; } @@ -105,15 +107,28 @@ impl TryFrom<&[&[u8]]> for CertificateChain { } } +/// Ensures the issuer matches the subject as specified in +/// https://datatracker.ietf.org/doc/html/rfc5280#section-7.1 +fn verify_name_chain(cert: &VerifiedCertificate, ca: &VerifiedCertificate) -> Result<()> { + let issuer = DistinguishedName::from(cert.issuer()); + let subject = DistinguishedName::from(ca.subject_name()); + + if issuer == subject { + Ok(()) + } else { + Err(Error::NameChaining) + } +} + fn verify_certificate_not_revoked( cert: &VerifiedCertificate, - trust_anchor: &VerifiedCertificate, + ca: &VerifiedCertificate, crls: &[UnverifiedCrl], unix_time: Duration, ) -> Result<()> { for crl in crls { - if crl.issuer() == trust_anchor.subject_name() { - let verified_crl = crl.verify(trust_anchor.public_key(), unix_time)?; + if crl.issuer() == ca.subject_name() { + let verified_crl = crl.verify(ca.public_key(), unix_time)?; if verified_crl.is_cert_revoked(cert.serial_number()) { return Err(Error::CertificateRevoked); } diff --git a/verifier/src/x509/error.rs b/verifier/src/x509/error.rs index bb3450a..4d7f12d 100644 --- a/verifier/src/x509/error.rs +++ b/verifier/src/x509/error.rs @@ -28,6 +28,8 @@ pub enum Error { CrlNotYetValid, /// Certificate revocation list missing next update time CrlMissingNextUpdate, + /// Issuer name does not match CAs subject name + NameChaining, } impl From<x509_cert::der::Error> for Error { diff --git a/verifier/src/x509/name.rs b/verifier/src/x509/name.rs new file mode 100644 index 0000000..b70a93e --- /dev/null +++ b/verifier/src/x509/name.rs @@ -0,0 +1,391 @@ +// Copyright (c) 2023 The MobileCoin Foundation + +//! X509 distinguished name as defined in sections +//! [4.1.2.4](https://datatracker.ietf.org/doc/html/rfc5280#section-4.1.2.4) and +//! [4.1.2.6](https://datatracker.ietf.org/doc/html/rfc5280#section-4.1.2.6) of +//! [RFC5280](https://datatracker.ietf.org/doc/html/rfc5280) +//! +//! Issuer and subject structure from +//! [4.1.2.4](https://datatracker.ietf.org/doc/html/rfc5280#section-4.1.2.4): +//! +//! ```ignore +//! Name ::= RDNSequence +//! RDNSequence ::= SEQUENCE OF RelativeDistinguishedName +//! RelativeDistinguishedName ::= SET OF AttributeTypeAndValue +//! AttributeTypeAndValue ::= SEQUENCE { +//! AttributeType, +//! AttributeValue +//! } +//! AttributeType ::= OBJECT IDENTIFIER +//! AttributeValue ::= DirectoryString +//! +//! DirectoryString ::= CHOICE { +//! TeletexString (Unsupported in this implementation). +//! PrintableString +//! UniversalString (Unsupported in this implementation) +//! UTF8String +//! BMPString (Unsupported in this implementation) +//! IA5String (See note below) +//! } +//! ``` +//! +//! Note: IA5String is is not called out in the BNF explanation of +//! [4.1.2.4](https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.4), but if +//! one reads X they will see: +//! +//! > In addition, implementations of this specification MUST be prepared +//! > to receive the domainComponent attribute, as defined in +//! > [RFC4519](https://www.rfc-editor.org/rfc/rfc4519). +//! +//! [RFC4519](https://www.rfc-editor.org/rfc/rfc4519) says says that `Ia5String` +//! will be used for the domain component. +//! +//! The [`DistinguishedName`] corresponds to the `Name` in the above hierarchy. +//! RFC5280 calls this distinguished name when used as the issuer or subject of +//! a certificate. +//! +//! The [`DirectoryString`] corresponds to the `DirectoryString` in the above +//! hierarchy. + +use super::rfc4518::Rfc4518String; +use x509_cert::attr::AttributeValue; +use x509_cert::der::asn1::{Ia5StringRef, PrintableStringRef, Utf8StringRef}; +use x509_cert::der::ErrorKind::TagUnknown; +use x509_cert::der::{Length, Tag, Tagged}; +use x509_cert::name::Name; + +#[derive(Debug)] +pub struct DistinguishedName<'a>(&'a Name); + +impl<'a> From<&'a Name> for DistinguishedName<'a> { + fn from(name: &'a Name) -> Self { + Self(name) + } +} + +/// Does `DistinguishedName` comparison as defined in +/// https://tools.ietf.org/html/rfc5280#section-7.1 +impl<'a> PartialEq for DistinguishedName<'a> { + fn eq(&self, other: &Self) -> bool { + let name_1 = self.0; + let name_2 = other.0; + + if name_1.0.len() != name_2.0.len() { + return false; + } + for (rdn_1, rdn_2) in name_1.0.iter().zip(name_2.0.iter()) { + if rdn_1.0.len() != rdn_2.0.len() { + return false; + } + for (attr_1, attr_2) in rdn_1.0.iter().zip(rdn_2.0.iter()) { + if attr_1.oid != attr_2.oid { + return false; + } + + let value_1 = match DirectoryString::try_from(&attr_1.value) { + Ok(value) => value, + Err(_) => return false, + }; + let value_2 = match DirectoryString::try_from(&attr_2.value) { + Ok(value) => value, + Err(_) => return false, + }; + + if value_1 != value_2 { + return false; + } + } + } + true + } +} + +#[derive(Debug)] +enum DirectoryString<'a> { + Printable(PrintableStringRef<'a>), + Utf8(Utf8StringRef<'a>), + Ia5(Ia5StringRef<'a>), +} + +/// Compares a `DirectoryString` as defined in +/// https://tools.ietf.org/html/rfc5280#section-7.1 +impl<'a> PartialEq for DirectoryString<'a> { + fn eq(&self, other: &Self) -> bool { + let string_1 = Rfc4518String::from(self); + let string_2 = Rfc4518String::from(other); + + let fold_1 = caseless::default_case_fold_str((&string_1).into()); + let fold_2 = caseless::default_case_fold_str((&string_2).into()); + + fold_1 == fold_2 + } +} + +impl<'a> TryFrom<&'a AttributeValue> for DirectoryString<'a> { + type Error = x509_cert::der::Error; + + fn try_from(value: &'a AttributeValue) -> Result<Self, Self::Error> { + match value.tag() { + Tag::PrintableString => Ok(DirectoryString::Printable(PrintableStringRef::try_from( + value, + )?)), + Tag::Utf8String => Ok(DirectoryString::Utf8(Utf8StringRef::try_from(value)?)), + Tag::Ia5String => Ok(DirectoryString::Ia5(Ia5StringRef::try_from(value)?)), + tag => Err(Self::Error::new( + TagUnknown { byte: tag.octet() }, + Length::from(0u8), + )), + } + } +} + +impl<'a> From<&DirectoryString<'a>> for &'a str { + fn from(value: &DirectoryString<'a>) -> &'a str { + match value { + DirectoryString::Printable(s) => s.as_str(), + DirectoryString::Utf8(s) => s.as_str(), + DirectoryString::Ia5(s) => s.as_str(), + } + } +} + +impl<'a> From<&DirectoryString<'a>> for Rfc4518String { + fn from(value: &DirectoryString<'a>) -> Rfc4518String { + let string: &str = value.into(); + Rfc4518String::from(string) + } +} + +#[cfg(test)] +mod test { + extern crate alloc; + + use super::*; + use alloc::vec; + use const_oid::db::rfc4519::ORGANIZATION_NAME; + use core::str::FromStr; + use rsa::pkcs8::der::asn1::SetOfVec; + use x509_cert::attr::AttributeTypeAndValue; + use x509_cert::der::asn1::TeletexStringRef; + use x509_cert::name::RelativeDistinguishedName; + use yare::parameterized; + + #[parameterized( + same_1 = {"Hello", "Hello"}, + same_2 = {"World", "World"}, + case_ignore_first = {"Title", "title"}, + case_ignore_all = {"ALL UPPER", "all upper"}, + space_compression = {"Hello World", "Hello World"}, + )] + fn compare_printable_strings(str_1: &str, str_2: &str) { + let string_1 = PrintableStringRef::new(str_1).expect("Failed to create PrintableStringRef"); + let value_1 = AttributeValue::from(string_1); + let directory_string_1 = + DirectoryString::try_from(&value_1).expect("Failed to convert to directory string"); + + let string_2 = PrintableStringRef::new(str_2).expect("Failed to create PrintableStringRef"); + let value_2 = AttributeValue::from(string_2); + let directory_string_2 = + DirectoryString::try_from(&value_2).expect("Failed to convert to directory string"); + + assert_eq!(directory_string_1, directory_string_2); + } + + // Code points for case folding taken from + // https://unicode.org/Public/UNIDATA/CaseFolding.txt + // A good reference for seeing the characters is + // https://www.compart.com/en/unicode/U+03A1 + // Change the `U+03A1` as appropriate in the url + #[parameterized( + same = {"Sure", "Sure"}, + case_fold_micro = {"Μ","µ"}, // U+039C, U+00B5 + case_fold_rho = {"Ρ", "ρ"}, // U+03A1, U+03C1 + case_fold_adlam_sha = {"\u{1E921}", "\u{1E943}"}, // not visible in most IDEs + )] + fn compare_utf8_strings(str_1: &str, str_2: &str) { + let string_1 = Utf8StringRef::new(str_1).expect("Failed to create Utf8StringRef"); + let value_1 = AttributeValue::from(string_1); + let directory_string_1 = + DirectoryString::try_from(&value_1).expect("Failed to convert to directory string"); + + let string_2 = Utf8StringRef::new(str_2).expect("Failed to create Utf8StringRef"); + let value_2 = AttributeValue::from(string_2); + let directory_string_2 = + DirectoryString::try_from(&value_2).expect("Failed to convert to directory string"); + + assert_eq!(directory_string_1, directory_string_2); + } + + #[parameterized( + same = {"string with @", "string with @"}, + case_fold = {"Capitalized Ampersand &", "capitalized ampersand &"}, + space_compression = {"A big distance from @", "A big distance from @"}, + )] + fn compare_ia5_strings(str_1: &str, str_2: &str) { + let string_1 = Ia5StringRef::new(str_1).expect("Failed to create Ia5StringRef"); + let value_1 = AttributeValue::from(string_1); + let directory_string_1 = + DirectoryString::try_from(&value_1).expect("Failed to convert to directory string"); + + let string_2 = Ia5StringRef::new(str_2).expect("Failed to create Ia5StringRef"); + let value_2 = AttributeValue::from(string_2); + let directory_string_2 = + DirectoryString::try_from(&value_2).expect("Failed to convert to directory string"); + + assert_eq!(directory_string_1, directory_string_2); + } + + #[test] + fn unsupported_directory_string_type() { + let teletex_string = + TeletexStringRef::new("Hello").expect("Failed to create TeletexStringRef"); + let attribute_value = AttributeValue::from(teletex_string); + let byte = Tag::TeletexString.octet(); + assert_eq!( + DirectoryString::try_from(&attribute_value), + Err(x509_cert::der::Error::new( + TagUnknown { byte }, + Length::from(0u8) + )) + ); + } + + #[parameterized( + name_1 = {"C=US,O=Test Certificates 2011,CN=Trust Anchor"}, + name_2 = {"C=US,O=Test Certificates 2011,CN=Good CA"}, + name_3 = {"C=US,O=Test Certificates 2011,CN=Valid EE Certificate Test1"}, + multiple_first_rdns = {"C=US+C=CA+C=UK,O=Test Certificates 2011,CN=Trust Anchor"}, + multiple_middle_rdns = {"C=US,O=Test Certificates 2011+O=More Stuff,CN=Trust Anchor"}, + multiple_last_rdns = {"C=US,O=Test Certificates 2011,CN=Trust Anchor+CN=You Know It"}, + )] + fn matched_distinguished_names(name: &str) { + let name_1 = Name::from_str(name).expect("Failed to parse name"); + let name_2 = name_1.clone(); + assert_eq!( + DistinguishedName::from(&name_1), + DistinguishedName::from(&name_2) + ); + } + + #[parameterized( + first = {"C=US,O=Test Certificates 2011,CN=Trust Anchor", "C=IS,O=Test Certificates 2011,CN=Trust Anchor"}, + middle = {"C=US,O=Test Certificates 2011,CN=Good CA", "C=US,O=Test Certificate 2011,CN=Good CA"}, + last = {"C=US,O=Test Certificates 2011,CN=Valid EE Certificate Test1", "C=US,O=Test Certificates 2011,CN=Invalid EE Certificate Test1"}, + different_lengths = {"C=US,O=Test Certificates 2011", "C=US,O=Test Certificates 2011,CN=Valid EE Certificate Test1"}, + different_rdn_lengths = {"C=US+C=CA+C=UK", "C=US+C=CA"}, + different_oids = {"C=US", "CN=US"}, + )] + fn mismatched_distinguished_names(name_1: &str, name_2: &str) { + let name_1 = Name::from_str(name_1).expect("Failed to parse name"); + let name_2 = Name::from_str(name_2).expect("Failed to parse name"); + assert_ne!( + DistinguishedName::from(&name_1), + DistinguishedName::from(&name_2) + ); + } + + #[test] + fn distinguished_name_build_up() { + // This test builds up a `DistinguishedName` type manually and compares + // to show that subsequent test cases fail due to unsupported string + // types and not a failure to build up the `DistinguishedName`. + let common_message = "Hello"; + let oid = ORGANIZATION_NAME; + + let string_1 = + PrintableStringRef::new(common_message).expect("Failed to create PrintableStringRef"); + let attribute_type_value_1 = AttributeTypeAndValue { + oid, + value: AttributeValue::from(string_1), + }; + let rdn_1 = RelativeDistinguishedName::from( + SetOfVec::try_from([attribute_type_value_1]) + .expect("Failed to build `RelativeDistinguishedName`"), + ); + let name_1 = Name::from(vec![rdn_1]); + + let string_2 = + PrintableStringRef::new(common_message).expect("Failed to create PrintableStringRef"); + let attribute_type_value_2 = AttributeTypeAndValue { + oid, + value: AttributeValue::from(string_2), + }; + let rdn_2 = RelativeDistinguishedName::from( + SetOfVec::try_from([attribute_type_value_2]) + .expect("Failed to build `RelativeDistinguishedName`"), + ); + let name_2 = Name::from(vec![rdn_2]); + assert_eq!( + DistinguishedName::from(&name_1), + DistinguishedName::from(&name_2) + ); + } + + #[test] + fn teletxstring_as_first_distinguished_name_fails() { + let common_message = "Hello"; + let oid = ORGANIZATION_NAME; + + let string_1 = + TeletexStringRef::new(common_message).expect("Failed to create TeletexStringRef"); + let attribute_type_value_1 = AttributeTypeAndValue { + oid, + value: AttributeValue::from(string_1), + }; + let rdn_1 = RelativeDistinguishedName::from( + SetOfVec::try_from([attribute_type_value_1]) + .expect("Failed to build `RelativeDistinguishedName`"), + ); + let name_1 = Name::from(vec![rdn_1]); + + let string_2 = + PrintableStringRef::new(common_message).expect("Failed to create PrintableStringRef"); + let attribute_type_value_2 = AttributeTypeAndValue { + oid, + value: AttributeValue::from(string_2), + }; + let rdn_2 = RelativeDistinguishedName::from( + SetOfVec::try_from([attribute_type_value_2]) + .expect("Failed to build `RelativeDistinguishedName`"), + ); + let name_2 = Name::from(vec![rdn_2]); + assert_ne!( + DistinguishedName::from(&name_1), + DistinguishedName::from(&name_2) + ); + } + + #[test] + fn teletxstring_as_second_distinguished_name_fails() { + let common_message = "Hello"; + let oid = ORGANIZATION_NAME; + + let string_1 = + PrintableStringRef::new(common_message).expect("Failed to create PrintableStringRef"); + let attribute_type_value_1 = AttributeTypeAndValue { + oid, + value: AttributeValue::from(string_1), + }; + let rdn_1 = RelativeDistinguishedName::from( + SetOfVec::try_from([attribute_type_value_1]) + .expect("Failed to build `RelativeDistinguishedName`"), + ); + let name_1 = Name::from(vec![rdn_1]); + + let string_2 = + TeletexStringRef::new(common_message).expect("Failed to create TeletexStringRef"); + let attribute_type_value_2 = AttributeTypeAndValue { + oid, + value: AttributeValue::from(string_2), + }; + let rdn_2 = RelativeDistinguishedName::from( + SetOfVec::try_from([attribute_type_value_2]) + .expect("Failed to build `RelativeDistinguishedName`"), + ); + let name_2 = Name::from(vec![rdn_2]); + assert_ne!( + DistinguishedName::from(&name_1), + DistinguishedName::from(&name_2) + ); + } +} diff --git a/verifier/src/x509/rfc4518.rs b/verifier/src/x509/rfc4518.rs new file mode 100644 index 0000000..a0b2c66 --- /dev/null +++ b/verifier/src/x509/rfc4518.rs @@ -0,0 +1,153 @@ +// Copyright (c) 2023 The MobileCoin Foundation + +//! An implementation of https://www.rfc-editor.org/rfc/rfc4518 string preparation. +//! +//! A good document on normal forms, https://unicode.org/reports/tr15/#Norm_Forms +extern crate alloc; + +use alloc::string::String; +use unicode_normalization::UnicodeNormalization; + +/// An RFC4518 prepared String +#[derive(Debug, PartialEq)] +pub struct Rfc4518String { + inner: String, +} + +impl From<&str> for Rfc4518String { + fn from(string: &str) -> Self { + let normalized = string + .chars() + .filter_map(rfc_4518_filter_map) + .nfkc() + .collect::<String>(); + let inner = space_compression(&normalized); + Self { inner } + } +} + +impl<'a> From<&'a Rfc4518String> for &'a str { + fn from(value: &'a Rfc4518String) -> &'a str { + &value.inner + } +} + +/// Perform step 2 of the string preparation, +/// https://www.rfc-editor.org/rfc/rfc4518#section-2.2 +/// +/// Note RFC4518 calls this step map, but it also _filters_ out certain +/// characters, thus the name deviation. +fn rfc_4518_filter_map(c: char) -> Option<char> { + // per https://doc.rust-lang.org/std/primitive.char.html#method.is_whitespace + // uses to the same values in + // https://www.rfc-editor.org/rfc/rfc4518#section-2.2 that map to space. + if c.is_whitespace() { + Some(' ') + } else { + rfc_4518_filter(c) + } +} + +/// Filter out characters from +/// https://www.rfc-editor.org/rfc/rfc4518#section-2.2 that map to nothing. +/// +/// Note it says: +/// +/// VARIATION SELECTORs (U+180B-180D, FF00-FE0F) +/// +/// However that should be `FE00-FE0F` as per +/// https://www.rfc-editor.org/rfc/rfc3454#appendix-B.1 +/// +fn rfc_4518_filter(c: char) -> Option<char> { + match c { + '\u{0000}'..='\u{0008}' + | '\u{000E}'..='\u{001F}' + | '\u{007F}'..='\u{0084}' + | '\u{0086}'..='\u{009F}' + | '\u{00AD}' + | '\u{034F}' + | '\u{06DD}' + | '\u{070F}' + | '\u{1806}' + | '\u{180B}'..='\u{180E}' + | '\u{200B}'..='\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{2063}' + | '\u{206A}'..='\u{206F}' + | '\u{FE00}'..='\u{FE0F}' + | '\u{FEFF}' + | '\u{FFF9}'..='\u{FFFC}' + | '\u{1D173}'..='\u{1D17A}' + | '\u{E0001}' + | '\u{E0020}'..='\u{E0074}' => None, + c => Some(c), + } +} + +/// Compress spaces as defined in https://www.rfc-editor.org/rfc/rfc4518#section-2.6.1 +/// +/// The resultant string will have: +/// - One leading space +/// - One trailing space. +/// - Any consecutive intermediate spaces will be converted to two spaces. i.e. +/// 4 consecutive spaces will be 2, but also 1 lone space will be converted +/// to 2 spaces. +fn space_compression(s: &str) -> String { + // Strings are either empty => <space><space> + // or they have a leading space so always start with a space + let mut result = String::from(" "); + + let mut last_char = ' '; + + for c in s.trim_end().chars() { + if c == ' ' { + if last_char == ' ' { + continue; + } else { + // An extra space for the two spaces specified in + // https://www.rfc-editor.org/rfc/rfc4518#section-2.6.1 + result.push(' '); + } + } + result.push(c); + last_char = c; + } + + // Strings are either empty => <space><space> + // or they have a trailing space so always end with a space + result.push(' '); + result +} + +#[cfg(test)] +mod test { + use super::*; + use yare::parameterized; + + #[parameterized( + empty = {"", " "}, + mulitple_spaces = {" ", " "}, + leading_and_trailing_space = {"Hello", " Hello "}, + intermediate_space = {"Hello world", " Hello world "}, + foo_space_bar_space_space = {"foo bar ", " foo bar "}, + so_many_spaces = {" What it is? ", " What it is? "}, + )] + fn insignificant_space_handling(input: &str, expected: &str) { + let result = space_compression(input); + assert_eq!(&result, expected); + } + + #[parameterized( + empty = {"", " "}, + ignored_control_character_0000 = {"Hello \u{0000}world", " Hello world "}, + ignored_control_character_0704 = {"Hello\u{070F}world", " Helloworld "}, + whitespace_is_the_same_as_space = {"\n\t\n\tHello\nworld\n\t\n\t\n", " Hello world "}, + normalizing_nfkc_fi = {"fi", " fi "}, // U+FB01, U+0066 U+0069 + normalizing_nfkc_25 = {"2⁵", " 25 "}, // U+0032 U+2075, U+0032 U+0035 + normalizing_nfkc_tel = {"℡", " TEL "}, // U+2121, U+0054 U+0045 U+004C + )] + fn to_rfc4518_string(input: &str, expected: &str) { + let result = Rfc4518String::from(input); + assert_eq!(&result.inner, expected); + } +}