feat(types): Added Rfc1123 String type, useful for k8s names
Some checks failed
Run Check Script / check (pull_request) Failing after 38s

This commit is contained in:
2025-12-15 12:48:56 -05:00
parent e9cab92585
commit e0da5764fb
2 changed files with 233 additions and 0 deletions

View File

@@ -2,3 +2,4 @@ pub mod id;
pub mod net;
pub mod storage;
pub mod switch;
pub mod rfc1123;

View File

@@ -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<Self, String> {
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<Rfc1123Name> 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
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");
}
}