relay-core 0.2.0-beta.4

The core components of the Relay Protocol.
Documentation
use serde::{
    Deserialize, Deserializer, Serialize, Serializer,
    de::{self, Visitor},
};

use crate::prelude::Address;

use super::{
    IdentityError as Err, canonical_identity_string, is_valid_fqdn, is_valid_identity_string,
};

/// A unique identifier for an Agent within an agent's domain.
/// https://gitlab.com/relay-mail/docs/-/wikis/Architecture/Identity#agent-identity
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AgentId(String);

impl From<Address> for AgentId {
    fn from(address: Address) -> Self {
        address.agent().clone()
    }
}

impl TryFrom<&str> for AgentId {
    type Error = Err;

    fn try_from(value: &str) -> Result<Self, Err> {
        AgentId::parse(value)
    }
}

impl TryFrom<String> for AgentId {
    type Error = Err;

    fn try_from(value: String) -> Result<Self, Err> {
        AgentId::parse(value)
    }
}

impl AgentId {
    /// Parse an AgentId from a string according to the identity rules.
    /// https://gitlab.com/relay-mail/docs/-/wikis/Architecture/Identity#agent-identity
    ///
    /// # Example
    /// ```
    /// let agent_id = relay_core::id::AgentId::parse("US.east.example.org").unwrap();
    /// assert_eq!(agent_id.canonical(), "us.east.example.org");
    /// ```
    pub fn parse(input: impl AsRef<str>) -> Result<Self, Err> {
        let input = input.as_ref();

        if input.is_empty() {
            return Err(Err::InvalidAgent);
        }
        if !is_valid_identity_string(input) {
            return Err(Err::InvalidIdentityString);
        }
        if !is_valid_fqdn(input) {
            return Err(Err::InvalidFqdnString);
        }

        let canonical = canonical_identity_string(input);

        Ok(Self(canonical))
    }

    /// Replace the current AgentId with a new value.
    pub fn replace(&mut self, new_value: impl AsRef<str>) -> Result<(), Err> {
        *self = Self::parse(new_value)?;
        Ok(())
    }

    /// The canonical form of the AgentId. The value is already stored as such.
    /// https://gitlab.com/relay-mail/docs/-/wikis/Architecture/Identity#canonical-form
    pub fn canonical(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for AgentId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.canonical())
    }
}

impl Serialize for AgentId {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        serializer.serialize_str(self.canonical())
    }
}

impl<'de> Deserialize<'de> for AgentId {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        struct AgentIdVisitor;

        impl<'de> Visitor<'de> for AgentIdVisitor {
            type Value = AgentId;

            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
                f.write_str("a canonical Relay Mail address string")
            }

            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
            where
                E: de::Error,
            {
                AgentId::parse(value)
                    .map_err(|e| de::Error::custom(format!("invalid address `{}`: {}", value, e)))
            }

            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
            where
                E: de::Error,
            {
                self.visit_str(&value)
            }
        }

        deserializer.deserialize_str(AgentIdVisitor)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_and_canonicalizes() {
        assert_eq!(
            AgentId::parse("US.example.org").unwrap().canonical(),
            "us.example.org"
        );
    }

    #[test]
    #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: InvalidAgent")]
    fn rejects_empty() {
        let _ = AgentId::parse("").unwrap();
    }

    #[test]
    #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: InvalidIdentityString")]
    fn rejects_invalid_chars() {
        let _ = AgentId::parse("Invalid Agent!").unwrap();
    }

    #[test]
    #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: InvalidIdentityString")]
    fn reject_untrimmed() {
        let _ = AgentId::parse("  trim_me  ").unwrap();
    }

    #[test]
    #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: InvalidIdentityString")]
    fn rejects_non_ascii() {
        let _ = AgentId::parse("调试输出").unwrap();
    }

    #[test]
    #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: InvalidIdentityString")]
    fn rejects_homoglyphs() {
        let _ = AgentId::parse("us.exaмple.org").unwrap(); // Cyrillic 'м'
    }

    #[test]
    #[should_panic(expected = "called `Result::unwrap()` on an `Err` value: InvalidFqdnString")]
    fn rejects_invalid_fqdn() {
        let _ = AgentId::parse("us_east.example.org").unwrap();
    }
}