use crate::error::ParseError;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Fqdn(String);
impl Fqdn {
pub fn new(domain: impl Into<String>) -> Result<Self, ParseError> {
let domain = domain.into();
if domain.is_empty() {
return Err(ParseError::InvalidFqdn("empty domain".to_string()));
}
let domain = domain.trim_end_matches('.');
for label in domain.split('.') {
if label.is_empty() {
return Err(ParseError::InvalidFqdn("empty label".to_string()));
}
if label.len() > 63 {
return Err(ParseError::InvalidFqdn("label too long".to_string()));
}
if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return Err(ParseError::InvalidFqdn(format!(
"invalid character in label: {label}"
)));
}
if label.starts_with('-') || label.ends_with('-') {
return Err(ParseError::InvalidFqdn(
"label cannot start or end with hyphen".to_string(),
));
}
}
Ok(Self(domain.to_lowercase()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn ans_badge_name(&self) -> String {
format!("_ans-badge.{}", self.0)
}
pub fn ra_badge_name(&self) -> String {
format!("_ra-badge.{}", self.0)
}
pub fn tlsa_name(&self, port: u16) -> String {
format!("_{port}._tcp.{}", self.0)
}
}
impl fmt::Display for Fqdn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for Fqdn {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl AsRef<str> for Fqdn {
fn as_ref(&self) -> &str {
&self.0
}
}
impl TryFrom<&str> for Fqdn {
type Error = ParseError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl TryFrom<String> for Fqdn {
type Error = ParseError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::new(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Version {
major: u32,
minor: u32,
patch: u32,
}
impl Version {
pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
Self {
major,
minor,
patch,
}
}
pub const fn major(&self) -> u32 {
self.major
}
pub const fn minor(&self) -> u32 {
self.minor
}
pub const fn patch(&self) -> u32 {
self.patch
}
pub fn parse(s: &str) -> Result<Self, ParseError> {
let s = s.strip_prefix('v').unwrap_or(s);
let parts: Vec<&str> = s.split('.').collect();
if parts.len() != 3 {
return Err(ParseError::InvalidVersion(format!(
"expected 3 parts, got {}: {}",
parts.len(),
s
)));
}
let major = parts[0].parse().map_err(|_| {
ParseError::InvalidVersion(format!("invalid major version: {}", parts[0]))
})?;
let minor = parts[1].parse().map_err(|_| {
ParseError::InvalidVersion(format!("invalid minor version: {}", parts[1]))
})?;
let patch = parts[2].parse().map_err(|_| {
ParseError::InvalidVersion(format!("invalid patch version: {}", parts[2]))
})?;
Ok(Self {
major,
minor,
patch,
})
}
}
impl fmt::Display for Version {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "v{}.{}.{}", self.major, self.minor, self.patch)
}
}
impl FromStr for Version {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl TryFrom<&str> for Version {
type Error = ParseError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::parse(s)
}
}
impl TryFrom<String> for Version {
type Error = ParseError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::parse(&s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AnsName {
version: Version,
fqdn: Fqdn,
}
impl AnsName {
pub fn version(&self) -> &Version {
&self.version
}
pub fn fqdn(&self) -> &Fqdn {
&self.fqdn
}
pub fn parse(uri: &str) -> Result<Self, ParseError> {
const PREFIX: &str = "ans://";
if !uri.starts_with(PREFIX) {
return Err(ParseError::InvalidAnsName(format!(
"ANS name must start with '{PREFIX}': {uri}"
)));
}
let rest = &uri[PREFIX.len()..];
if !rest.starts_with('v') {
return Err(ParseError::InvalidAnsName(format!(
"ANS name version must start with 'v': {uri}"
)));
}
let parts: Vec<&str> = rest.splitn(4, '.').collect();
if parts.len() < 4 {
return Err(ParseError::InvalidAnsName(format!(
"ANS name must have format 'ans://vX.Y.Z.fqdn', got: {uri}"
)));
}
let version_str = format!("{}.{}.{}", parts[0], parts[1], parts[2]);
let version = Version::parse(&version_str)?;
let fqdn = Fqdn::new(parts[3])?;
Ok(Self { version, fqdn })
}
}
impl fmt::Display for AnsName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ans://{}.{}", self.version, self.fqdn)
}
}
impl FromStr for AnsName {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl Serialize for AnsName {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for AnsName {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::parse(&s).map_err(serde::de::Error::custom)
}
}
impl TryFrom<&str> for AnsName {
type Error = ParseError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::parse(s)
}
}
impl TryFrom<String> for AnsName {
type Error = ParseError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::parse(&s)
}
}
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#[cfg(test)]
mod tests {
use super::*;
mod fqdn_tests {
use super::*;
#[test]
fn test_valid_fqdn() {
let fqdn = Fqdn::new("agent.example.com").unwrap();
assert_eq!(fqdn.as_str(), "agent.example.com");
}
#[test]
fn test_fqdn_with_trailing_dot() {
let fqdn = Fqdn::new("agent.example.com.").unwrap();
assert_eq!(fqdn.as_str(), "agent.example.com");
}
#[test]
fn test_fqdn_lowercased() {
let fqdn = Fqdn::new("Agent.Example.COM").unwrap();
assert_eq!(fqdn.as_str(), "agent.example.com");
}
#[test]
fn test_ans_badge_name() {
let fqdn = Fqdn::new("agent.example.com").unwrap();
assert_eq!(fqdn.ans_badge_name(), "_ans-badge.agent.example.com");
}
#[test]
fn test_ra_badge_name() {
let fqdn = Fqdn::new("agent.example.com").unwrap();
assert_eq!(fqdn.ra_badge_name(), "_ra-badge.agent.example.com");
}
#[test]
fn test_tlsa_name() {
let fqdn = Fqdn::new("agent.example.com").unwrap();
assert_eq!(fqdn.tlsa_name(443), "_443._tcp.agent.example.com");
}
#[test]
fn test_empty_fqdn() {
assert!(Fqdn::new("").is_err());
}
#[test]
fn test_fqdn_with_invalid_chars() {
assert!(Fqdn::new("agent_test.example.com").is_err());
}
}
mod version_tests {
use super::*;
#[test]
fn test_parse_with_v_prefix() {
let v = Version::parse("v1.2.3").unwrap();
assert_eq!(v.major(), 1);
assert_eq!(v.minor(), 2);
assert_eq!(v.patch(), 3);
}
#[test]
fn test_parse_without_v_prefix() {
let v = Version::parse("1.2.3").unwrap();
assert_eq!(v.major(), 1);
assert_eq!(v.minor(), 2);
assert_eq!(v.patch(), 3);
}
#[test]
fn test_version_display() {
let v = Version::new(1, 2, 3);
assert_eq!(v.to_string(), "v1.2.3");
}
#[test]
fn test_version_ordering() {
let v1 = Version::new(1, 0, 0);
let v2 = Version::new(1, 0, 1);
let v3 = Version::new(1, 1, 0);
let v4 = Version::new(2, 0, 0);
assert!(v1 < v2);
assert!(v2 < v3);
assert!(v3 < v4);
}
#[test]
fn test_invalid_version() {
assert!(Version::parse("1.2").is_err());
assert!(Version::parse("1.2.3.4").is_err());
assert!(Version::parse("a.b.c").is_err());
}
}
mod fqdn_extra_tests {
use super::*;
#[test]
fn test_fqdn_single_label() {
let fqdn = Fqdn::new("localhost").unwrap();
assert_eq!(fqdn.as_str(), "localhost");
}
#[test]
fn test_fqdn_label_too_long() {
let long_label = "a".repeat(64);
assert!(Fqdn::new(&long_label).is_err());
}
#[test]
fn test_fqdn_leading_hyphen() {
assert!(Fqdn::new("-example.com").is_err());
}
#[test]
fn test_fqdn_trailing_hyphen() {
assert!(Fqdn::new("example-.com").is_err());
}
#[test]
fn test_fqdn_double_dots() {
assert!(Fqdn::new("agent..example.com").is_err());
}
#[test]
fn test_fqdn_display() {
let fqdn = Fqdn::new("agent.example.com").unwrap();
assert_eq!(format!("{fqdn}"), "agent.example.com");
}
#[test]
fn test_fqdn_as_ref() {
let fqdn = Fqdn::new("agent.example.com").unwrap();
let s: &str = fqdn.as_ref();
assert_eq!(s, "agent.example.com");
}
#[test]
fn test_fqdn_try_from_str() {
let fqdn = Fqdn::try_from("agent.example.com").unwrap();
assert_eq!(fqdn.as_str(), "agent.example.com");
}
#[test]
fn test_fqdn_try_from_string() {
let fqdn = Fqdn::try_from("agent.example.com".to_string()).unwrap();
assert_eq!(fqdn.as_str(), "agent.example.com");
}
#[test]
fn test_fqdn_from_str() {
let fqdn: Fqdn = "agent.example.com".parse().unwrap();
assert_eq!(fqdn.as_str(), "agent.example.com");
}
}
mod version_extra_tests {
use super::*;
#[test]
fn test_version_try_from_str() {
let v = Version::try_from("v1.2.3").unwrap();
assert_eq!(v, Version::new(1, 2, 3));
}
#[test]
fn test_version_try_from_string() {
let v = Version::try_from("1.2.3".to_string()).unwrap();
assert_eq!(v, Version::new(1, 2, 3));
}
#[test]
fn test_version_from_str() {
let v: Version = "v1.0.0".parse().unwrap();
assert_eq!(v, Version::new(1, 0, 0));
}
#[test]
fn test_version_hash_equality() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(Version::new(1, 0, 0));
assert!(set.contains(&Version::new(1, 0, 0)));
assert!(!set.contains(&Version::new(1, 0, 1)));
}
}
mod ans_name_tests {
use super::*;
#[test]
fn test_parse_ans_name() {
let name = AnsName::parse("ans://v1.0.0.agent.example.com").unwrap();
assert_eq!(name.version, Version::new(1, 0, 0));
assert_eq!(name.fqdn.as_str(), "agent.example.com");
}
#[test]
fn test_parse_ans_name_complex_fqdn() {
let name = AnsName::parse("ans://v2.1.3.agent.example.com").unwrap();
assert_eq!(name.version, Version::new(2, 1, 3));
assert_eq!(name.fqdn.as_str(), "agent.example.com");
}
#[test]
fn test_invalid_ans_name_no_prefix() {
assert!(AnsName::parse("v1.0.0.agent.example.com").is_err());
}
#[test]
fn test_invalid_ans_name_no_version() {
assert!(AnsName::parse("ans://agent.example.com").is_err());
}
#[test]
fn test_ans_name_display() {
let name = AnsName::parse("ans://v1.0.0.agent.example.com").unwrap();
assert_eq!(format!("{name}"), "ans://v1.0.0.agent.example.com");
}
#[test]
fn test_ans_name_serde_roundtrip() {
let name = AnsName::parse("ans://v1.0.0.agent.example.com").unwrap();
let json = serde_json::to_string(&name).unwrap();
let deserialized: AnsName = serde_json::from_str(&json).unwrap();
assert_eq!(name, deserialized);
}
#[test]
fn test_ans_name_serde_invalid() {
let result = serde_json::from_str::<AnsName>(r#""not-an-ans-name""#);
assert!(result.is_err());
}
#[test]
fn test_ans_name_try_from_str() {
let name = AnsName::try_from("ans://v1.0.0.agent.example.com").unwrap();
assert_eq!(name.version(), &Version::new(1, 0, 0));
}
#[test]
fn test_ans_name_try_from_string() {
let name = AnsName::try_from("ans://v1.0.0.agent.example.com".to_string()).unwrap();
assert_eq!(name.fqdn().as_str(), "agent.example.com");
}
#[test]
fn test_ans_name_from_str() {
let name: AnsName = "ans://v1.0.0.agent.example.com".parse().unwrap();
assert_eq!(name.version(), &Version::new(1, 0, 0));
}
#[test]
fn test_ans_name_accessors() {
let name = AnsName::parse("ans://v2.1.3.agent.example.com").unwrap();
assert_eq!(name.version(), &Version::new(2, 1, 3));
assert_eq!(name.fqdn().as_str(), "agent.example.com");
}
#[test]
fn test_ans_name_no_v_prefix_error() {
let result = AnsName::parse("ans://1.0.0.agent.example.com");
assert!(result.is_err());
}
}
}