runewarp 0.1.0

Runewarp is an ingress tunneling tool for exposing local services without moving TLS termination to the edge. Clients connect out over QUIC, so you can publish services without putting your backend directly on the Internet or leaking your public IP.
Documentation
use std::fmt;
use std::net::IpAddr;

pub(crate) fn normalize_public_hostname(hostname: &str) -> String {
    hostname.trim_end_matches('.').to_ascii_lowercase()
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PublicHostnameError {
    Empty,
    TooLong,
    EmptyLabel,
    LabelTooLong,
    InvalidCharacter,
    LeadingOrTrailingHyphen,
    Wildcard,
    IpLiteral,
    RawUnicode,
}

impl fmt::Display for PublicHostnameError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("hostname must not be empty"),
            Self::TooLong => formatter.write_str("hostname must be 253 bytes or shorter"),
            Self::EmptyLabel => formatter.write_str("hostname labels must not be empty"),
            Self::LabelTooLong => {
                formatter.write_str("hostname labels must be 63 bytes or shorter")
            }
            Self::InvalidCharacter => formatter.write_str(
                "hostname must contain only lowercase ASCII letters, digits, dots, and hyphens",
            ),
            Self::LeadingOrTrailingHyphen => {
                formatter.write_str("hostname labels must not start or end with a hyphen")
            }
            Self::Wildcard => formatter.write_str("wildcard hostnames are not supported"),
            Self::IpLiteral => formatter.write_str("IP literals are not supported"),
            Self::RawUnicode => formatter.write_str("raw Unicode hostnames are not supported"),
        }
    }
}

pub(crate) fn validate_public_hostname(hostname: &str) -> Result<String, PublicHostnameError> {
    let normalized = normalize_public_hostname(hostname);
    if normalized.is_empty() {
        return Err(PublicHostnameError::Empty);
    }
    if !normalized.is_ascii() {
        return Err(PublicHostnameError::RawUnicode);
    }
    if normalized.contains('*') {
        return Err(PublicHostnameError::Wildcard);
    }
    if normalized.parse::<IpAddr>().is_ok() {
        return Err(PublicHostnameError::IpLiteral);
    }
    if normalized.len() > 253 {
        return Err(PublicHostnameError::TooLong);
    }

    for label in normalized.split('.') {
        if label.is_empty() {
            return Err(PublicHostnameError::EmptyLabel);
        }
        if label.len() > 63 {
            return Err(PublicHostnameError::LabelTooLong);
        }
        if label.starts_with('-') || label.ends_with('-') {
            return Err(PublicHostnameError::LeadingOrTrailingHyphen);
        }
        if !label
            .bytes()
            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
        {
            return Err(PublicHostnameError::InvalidCharacter);
        }
    }

    Ok(normalized)
}

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

    use super::{PublicHostnameError, normalize_public_hostname, validate_public_hostname};

    #[test]
    fn lowercases_public_hostnames() {
        assert_eq!(
            normalize_public_hostname("App.Example.Test"),
            "app.example.test"
        );
    }

    #[test]
    fn strips_a_trailing_dot_from_public_hostnames() {
        assert_eq!(
            normalize_public_hostname("app.example.test."),
            "app.example.test"
        );
    }

    #[test]
    fn strips_all_trailing_dots_from_public_hostnames() {
        assert_eq!(
            normalize_public_hostname("app.example.test..."),
            "app.example.test"
        );
        assert_eq!(normalize_public_hostname(".."), "");
    }

    #[test]
    fn accepts_punycode_a_labels() {
        assert_eq!(
            validate_public_hostname("XN--BCHER-KVA.example").unwrap(),
            "xn--bcher-kva.example"
        );
    }

    #[test]
    fn rejects_raw_unicode_hostnames() {
        assert_eq!(
            validate_public_hostname("bücher.example").unwrap_err(),
            PublicHostnameError::RawUnicode
        );
    }

    #[test]
    fn rejects_wildcard_hostnames() {
        assert_eq!(
            validate_public_hostname("*.example.test").unwrap_err(),
            PublicHostnameError::Wildcard
        );
    }

    #[test]
    fn rejects_ip_literals() {
        assert_eq!(
            validate_public_hostname("127.0.0.1").unwrap_err(),
            PublicHostnameError::IpLiteral
        );
    }

    proptest! {
        #![proptest_config(ProptestConfig::with_cases(32))]

        #[test]
        fn normalization_is_idempotent(hostname in ".*") {
            let normalized = normalize_public_hostname(&hostname);
            prop_assert_eq!(normalize_public_hostname(&normalized), normalized);
        }

        #[test]
        fn validated_hostnames_remain_in_canonical_form(hostname in ".*") {
            if let Ok(validated) = validate_public_hostname(&hostname) {
                prop_assert_eq!(normalize_public_hostname(&validated), validated.clone());
                prop_assert_eq!(validate_public_hostname(&validated), Ok(validated.clone()));
            }
        }
    }
}