relay-core 0.1.1-alpha.0

The core components of the Relay Protocol.
Documentation
mod address;
mod agent;
mod inbox;
mod user;

pub use address::Address;
pub use agent::AgentId;
pub use inbox::InboxId;
pub use user::UserId;

#[derive(Debug, PartialEq, Eq, thiserror::Error)]
pub enum IdentityError {
    #[error("invalid agent id")]
    InvalidAgent,
    #[error("invalid user id")]
    InvalidUser,
    #[error("invalid inbox")]
    InvalidInbox,
    #[error("invalid address")]
    InvalidAddress,
    #[error("invalid identity string")]
    InvalidIdentityString,
    #[error("invalid fqdn string")]
    InvalidFqdnString,
}

/// Allowed special characters in identity strings.
/// https://gitlab.com/relay-mail/docs/-/wikis/Architecture/Identity
pub const ALLOWED_SPECIAL_CHARS: &str = ".!$%&'*+-/=?^_`{}~";

/// Check if the string passes the validation for identities.
/// Only allows alphanumeric characters and a set of special characters.
/// https://gitlab.com/relay-mail/docs/-/wikis/Architecture/Identity
pub fn is_valid_identity_string(input: &str) -> bool {
    input
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || ALLOWED_SPECIAL_CHARS.contains(c))
}

/// Convert the identity string to its canonical form by lowercasing it.
/// https://gitlab.com/relay-mail/docs/-/wikis/Architecture/Identity#canonical-form
pub fn canonical_identity_string(input: &str) -> String {
    input.to_lowercase()
}

/// Check if a DNS label is valid according to RFC 1035.
pub fn is_valid_dns_label(label: &str) -> bool {
    let len = label.len();
    if len == 0 || len > 63 {
        return false;
    }

    let bytes = label.as_bytes();

    if !bytes[0].is_ascii_alphanumeric() || !bytes[len - 1].is_ascii_alphanumeric() {
        return false;
    }

    bytes
        .iter()
        .all(|b| b.is_ascii_alphanumeric() || *b == b'-')
}

/// Check if a fully qualified domain name (FQDN) is valid according to RFC 1035.
pub fn is_valid_fqdn(input: &str) -> bool {
    if input.len() > 253 {
        return false;
    }

    if input.ends_with('.') {
        return false;
    }

    let labels: Vec<&str> = input.split('.').collect();
    if labels.len() < 2 {
        return false;
    }

    labels.iter().all(|label| is_valid_dns_label(label))
}

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

    mod identity_string {
        use super::*;

        // BEFORE CHANGING: Make sure that the allowed characters match the documentation!
        // https://gitlab.com/relay-mail/docs/-/wikis/Architecture/Identity
        #[test]
        fn correct_allowed_chars() {
            let test_str = "abcXYZ0123456789.!$%&'*+-/=?^_`{}~";
            assert!(is_valid_identity_string(test_str));
        }

        #[test]
        fn rejects_common_invalid_chars() {
            assert!(!is_valid_identity_string("#"));
            assert!(!is_valid_identity_string("@"));
            assert!(!is_valid_identity_string("|"));
            assert!(!is_valid_identity_string(" "));
        }

        #[test]
        fn canonicalizes_properly() {
            let input = "AbC.!$%&'*+-/=?^_`{}~XyZ0123456789";
            let expected = "abc.!$%&'*+-/=?^_`{}~xyz0123456789";
            assert_eq!(canonical_identity_string(input), expected);
        }
    }

    mod dns_label {
        use super::*;

        #[test]
        fn valid_labels() {
            assert!(is_valid_dns_label("example"));
            assert!(is_valid_dns_label("ex-ample"));
            assert!(is_valid_dns_label("e"));
            assert!(is_valid_dns_label("a".repeat(63).as_str()));
        }

        #[test]
        fn invalid_labels() {
            assert!(!is_valid_dns_label(""));
            assert!(!is_valid_dns_label("a".repeat(64).as_str()));
            assert!(!is_valid_dns_label("-example"));
            assert!(!is_valid_dns_label("example-"));
            assert!(!is_valid_dns_label("ex_ample"));
            assert!(!is_valid_dns_label("ex ample"));
        }
    }

    mod fqdn {
        use super::*;

        #[test]
        fn valid_fqdns() {
            assert!(is_valid_fqdn("example.org"));
            assert!(is_valid_fqdn("sub.example.org"));
            assert!(is_valid_fqdn(
                "a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z"
            ));

            let labels = [
                "a".repeat(63),
                "b".repeat(63),
                "c".repeat(63),
                "d".repeat(61),
            ];
            let fqdn = labels.join(".");
            assert_eq!(fqdn.len(), 253);
            assert!(is_valid_fqdn(&fqdn));
        }

        #[test]
        fn invalid_fqdns() {
            assert!(!is_valid_fqdn(""));
            assert!(!is_valid_fqdn("example."));
            assert!(!is_valid_fqdn("ex ample.org"));
            assert!(!is_valid_fqdn("example_org"));
            assert!(!is_valid_fqdn(&format!("{}.org", "a".repeat(247))));
            assert!(!is_valid_fqdn("a..b.org"));
            assert!(!is_valid_fqdn("a.-b.org"));
        }
    }
}