diff --git a/harmony_types/src/lib.rs b/harmony_types/src/lib.rs index 1ebdb97..3d72c80 100644 --- a/harmony_types/src/lib.rs +++ b/harmony_types/src/lib.rs @@ -2,3 +2,4 @@ pub mod id; pub mod net; pub mod storage; pub mod switch; +pub mod rfc1123; diff --git a/harmony_types/src/rfc1123.rs b/harmony_types/src/rfc1123.rs new file mode 100644 index 0000000..190d8c4 --- /dev/null +++ b/harmony_types/src/rfc1123.rs @@ -0,0 +1,232 @@ +/// A String that can be used as a subdomain. +/// +/// This means the name must: +/// +/// - contain no more than 253 characters +/// - contain only lowercase alphanumeric characters, '-' or '.' +/// - start with an alphanumeric character +/// - end with an alphanumeric character +/// +/// https://datatracker.ietf.org/doc/html/rfc1123 +/// +/// This is relevant in harmony since most k8s resource names are required to be usable as dns +/// subdomains. +/// +/// See https://kubernetes.io/docs/concepts/overview/working-with-objects/names/ +#[derive(Debug, Clone)] +pub struct Rfc1123Name { + content: String, +} + +impl TryFrom<&str> for Rfc1123Name { + fn try_from(s: &str) -> Result { + let mut content = s.to_lowercase(); + + // Remove invalid characters + content.retain(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.'); + + // Enforce max length + if content.len() > 253 { + content.truncate(253); + } + + // Trim leading/trailing dots + content = content.trim_matches('.').to_string(); + + // Deduplicate consecutive dots + loop { + let new_content = content.replace("..", "."); + if new_content == content { + break; + } + content = new_content; + } + + // Trim leading/trailing non-alphanumeric + content = content.trim_matches(|c: char| !c.is_ascii_alphanumeric()).to_string(); + + if content.is_empty() { + return Err(format!("Input '{}' resulted in empty string", s)); + } + + Ok(Self { content }) + } + + type Error = String; +} + + +/// Converts an `Rfc1123Name` into a `String`. +/// +/// This allows using `Rfc1123Name` in contexts where a `String` is expected. +impl From for String { + fn from(name: Rfc1123Name) -> Self { + name.content + } +} + +/// Serializes the `Rfc1123Name` as a string. +/// +/// This directly serializes the inner `String` content without additional wrapping. +impl serde::Serialize for Rfc1123Name { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.content) + } +} + +/// Deserializes an `Rfc1123Name` from a string. +/// +/// This directly deserializes into the inner `String` content without additional wrapping. +impl<'de> serde::Deserialize<'de> for Rfc1123Name { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let content = String::deserialize(deserializer)?; + Ok(Self { content }) + } +} + +/// Displays the `Rfc1123Name` as a string. +/// +/// This directly displays the inner `String` content without additional wrapping. +impl std::fmt::Display for Rfc1123Name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.content) + } +} + + +#[cfg(test)] +mod tests { + use super::Rfc1123Name; + + #[test] + fn test_try_from_empty() { + let name = Rfc1123Name::try_from(""); + assert!(name.is_err()); + } + + #[test] + fn test_try_from_valid() { + let name = Rfc1123Name::try_from("hello-world").unwrap(); + assert_eq!(name.content, "hello-world"); + } + + #[test] + fn test_try_from_uppercase() { + let name = Rfc1123Name::try_from("Hello-World").unwrap(); + assert_eq!(name.content, "hello-world"); + } + + #[test] + fn test_try_from_invalid_chars() { + let name = Rfc1123Name::try_from("hel@lo#w!or%ld123").unwrap(); + assert_eq!(name.content, "helloworld123"); + } + + #[test] + fn test_try_from_leading_dot() { + let name = Rfc1123Name::try_from(".hello").unwrap(); + assert_eq!(name.content, "hello"); + } + + #[test] + fn test_try_from_trailing_dot() { + let name = Rfc1123Name::try_from("hello.").unwrap(); + assert_eq!(name.content, "hello"); + } + + #[test] + fn test_try_from_leading_hyphen() { + let name = Rfc1123Name::try_from("-hello").unwrap(); + assert_eq!(name.content, "hello"); + } + + #[test] + fn test_try_from_complicated_string() { + let name = Rfc1123Name::try_from("--h--e,}{}12!$#)\np_aulbS\r\t.!@o--._--").unwrap(); + assert_eq!(name.content, "h--e12paulbs.o"); + } + + #[test] + fn test_try_from_trailing_hyphen() { + let name = Rfc1123Name::try_from("hello-").unwrap(); + assert_eq!(name.content, "hello"); + } + + #[test] + fn test_try_from_single_hyphen() { + let name = Rfc1123Name::try_from("-"); + assert!(name.is_err()); + } + + #[test] + fn test_from_str() { + let name: Rfc1123Name = "test-name".try_into().unwrap(); + assert_eq!(name.content, "test-name"); + } + + #[test] + fn test_into_string() { + let name = Rfc1123Name::try_from("test").unwrap(); + let s: String = name.into(); + assert_eq!(s, "test"); + } + + #[test] + fn test_compliance() { + let inputs = vec![ + "valid", + "in-VALID", + ".dots", + "-hyphen", + "hyphen-", + "!!1@", + "aaaaaaaaaa", + "--abc--", + "a.b-c", + ]; + + for input in inputs { + let name = Rfc1123Name::try_from(input).unwrap(); + let s = &name.content; + // Check only allowed characters + for c in s.chars() { + assert!(c.is_ascii_alphanumeric() || c == '-' || c == '.'); + } + // Check starts and ends with alphanumeric + if !s.is_empty() { + assert!(s.chars().next().unwrap().is_ascii_alphanumeric()); + assert!(s.chars().last().unwrap().is_ascii_alphanumeric()); + } + } + } + + #[test] + fn test_enforces_max_length() { + let long_input = "a".repeat(300); + let name = Rfc1123Name::try_from(long_input.as_str()).unwrap(); + assert_eq!(name.content.len(), 253); + assert_eq!(name.content, "a".repeat(253)); + } + + #[test] + fn test_truncate_trim_end() { + let input = "a".repeat(252) + "-"; + let name = Rfc1123Name::try_from(input.as_str()).unwrap(); + assert_eq!(name.content.len(), 252); + assert_eq!(name.content, "a".repeat(252)); + } + + #[test] + fn test_dedup_dots() { + let input = "a..b...c"; + let name = Rfc1123Name::try_from(input).unwrap(); + assert_eq!(name.content, "a.b.c"); + } +} +