lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

relay_url.rs (6858B)


      1 #![forbid(unsafe_code)]
      2 
      3 use std::fmt;
      4 
      5 #[derive(Debug, Clone, PartialEq, Eq)]
      6 pub enum RelayUrlValidationError {
      7     Empty,
      8     UnsupportedScheme(String),
      9     MissingHost(String),
     10     InvalidAuthority(String),
     11     InvalidPort(String),
     12 }
     13 
     14 impl fmt::Display for RelayUrlValidationError {
     15     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
     16         match self {
     17             Self::Empty => f.write_str("relay url must not be empty"),
     18             Self::UnsupportedScheme(value) => {
     19                 write!(f, "relay url must use ws or wss, got `{value}`")
     20             }
     21             Self::MissingHost(value) => write!(f, "relay url must include a host, got `{value}`"),
     22             Self::InvalidAuthority(value) => {
     23                 write!(f, "relay url authority is invalid, got `{value}`")
     24             }
     25             Self::InvalidPort(value) => write!(f, "relay url port is invalid, got `{value}`"),
     26         }
     27     }
     28 }
     29 
     30 impl std::error::Error for RelayUrlValidationError {}
     31 
     32 pub fn normalize_relay_url(value: &str) -> Result<String, RelayUrlValidationError> {
     33     let trimmed = value.trim();
     34     if trimmed.is_empty() {
     35         return Err(RelayUrlValidationError::Empty);
     36     }
     37 
     38     let rest = if let Some(rest) = trimmed.strip_prefix("ws://") {
     39         rest
     40     } else if let Some(rest) = trimmed.strip_prefix("wss://") {
     41         rest
     42     } else {
     43         return Err(RelayUrlValidationError::UnsupportedScheme(
     44             trimmed.to_owned(),
     45         ));
     46     };
     47 
     48     validate_relay_authority(trimmed, rest)?;
     49     Ok(trimmed.to_owned())
     50 }
     51 
     52 pub fn normalize_relay_urls<I, S>(values: I) -> Result<Vec<String>, RelayUrlValidationError>
     53 where
     54     I: IntoIterator<Item = S>,
     55     S: AsRef<str>,
     56 {
     57     let mut normalized = Vec::new();
     58     for value in values {
     59         let relay = normalize_relay_url(value.as_ref())?;
     60         if !normalized.iter().any(|existing| existing == &relay) {
     61             normalized.push(relay);
     62         }
     63     }
     64     Ok(normalized)
     65 }
     66 
     67 fn validate_relay_authority(original: &str, rest: &str) -> Result<(), RelayUrlValidationError> {
     68     let authority_end = rest
     69         .char_indices()
     70         .find(|(_, ch)| matches!(ch, '/' | '?' | '#'))
     71         .map(|(index, _)| index)
     72         .unwrap_or(rest.len());
     73     let authority = &rest[..authority_end];
     74 
     75     if authority.is_empty() {
     76         return Err(RelayUrlValidationError::MissingHost(original.to_owned()));
     77     }
     78     if authority.chars().any(char::is_whitespace) || authority.contains('@') {
     79         return Err(RelayUrlValidationError::InvalidAuthority(
     80             original.to_owned(),
     81         ));
     82     }
     83 
     84     if let Some(after_open) = authority.strip_prefix('[') {
     85         let Some(close_index) = after_open.find(']') else {
     86             return Err(RelayUrlValidationError::InvalidAuthority(
     87                 original.to_owned(),
     88             ));
     89         };
     90         let host = &after_open[..close_index];
     91         let after_host = &after_open[close_index + 1..];
     92         if host.is_empty() {
     93             return Err(RelayUrlValidationError::MissingHost(original.to_owned()));
     94         }
     95         validate_optional_port(original, after_host)?;
     96         return Ok(());
     97     }
     98 
     99     if authority.bytes().filter(|byte| *byte == b':').count() > 1 {
    100         return Err(RelayUrlValidationError::InvalidAuthority(
    101             original.to_owned(),
    102         ));
    103     }
    104     let Some((host, port)) = authority.split_once(':') else {
    105         return Ok(());
    106     };
    107     if host.is_empty() {
    108         return Err(RelayUrlValidationError::MissingHost(original.to_owned()));
    109     }
    110     validate_port(original, port)
    111 }
    112 
    113 fn validate_optional_port(original: &str, after_host: &str) -> Result<(), RelayUrlValidationError> {
    114     if after_host.is_empty() {
    115         return Ok(());
    116     }
    117     let Some(port) = after_host.strip_prefix(':') else {
    118         return Err(RelayUrlValidationError::InvalidAuthority(
    119             original.to_owned(),
    120         ));
    121     };
    122     validate_port(original, port)
    123 }
    124 
    125 fn validate_port(original: &str, port: &str) -> Result<(), RelayUrlValidationError> {
    126     if port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) {
    127         return Err(RelayUrlValidationError::InvalidPort(original.to_owned()));
    128     }
    129     Ok(())
    130 }
    131 
    132 #[cfg(test)]
    133 mod tests {
    134     use super::*;
    135 
    136     #[test]
    137     fn display_formats_all_validation_errors() {
    138         assert_eq!(
    139             RelayUrlValidationError::Empty.to_string(),
    140             "relay url must not be empty"
    141         );
    142         assert_eq!(
    143             RelayUrlValidationError::UnsupportedScheme("http://relay.test".to_owned()).to_string(),
    144             "relay url must use ws or wss, got `http://relay.test`"
    145         );
    146         assert_eq!(
    147             RelayUrlValidationError::MissingHost("ws://".to_owned()).to_string(),
    148             "relay url must include a host, got `ws://`"
    149         );
    150         assert_eq!(
    151             RelayUrlValidationError::InvalidAuthority("ws://user@relay.test".to_owned())
    152                 .to_string(),
    153             "relay url authority is invalid, got `ws://user@relay.test`"
    154         );
    155         assert_eq!(
    156             RelayUrlValidationError::InvalidPort("ws://relay.test:x".to_owned()).to_string(),
    157             "relay url port is invalid, got `ws://relay.test:x`"
    158         );
    159     }
    160 
    161     #[test]
    162     fn normalize_relay_url_covers_authority_edges() {
    163         assert_eq!(
    164             normalize_relay_url(" wss://relay.test:443/path?x=1#fragment ")
    165                 .expect("normalized relay"),
    166             "wss://relay.test:443/path?x=1#fragment"
    167         );
    168         assert_eq!(
    169             normalize_relay_url("ws://[::1]:8080").expect("ipv6 relay"),
    170             "ws://[::1]:8080"
    171         );
    172         assert_eq!(
    173             normalize_relay_url("ws://[::1]").expect("ipv6 relay without port"),
    174             "ws://[::1]"
    175         );
    176         assert!(matches!(
    177             normalize_relay_url("ws://"),
    178             Err(RelayUrlValidationError::MissingHost(_))
    179         ));
    180         assert!(matches!(
    181             normalize_relay_url("ws://:8080"),
    182             Err(RelayUrlValidationError::MissingHost(_))
    183         ));
    184         assert!(matches!(
    185             normalize_relay_url("ws://relay.test:8080:9090"),
    186             Err(RelayUrlValidationError::InvalidAuthority(_))
    187         ));
    188     }
    189 
    190     #[test]
    191     fn normalize_relay_urls_dedupes_while_preserving_order() {
    192         let relays = normalize_relay_urls([
    193             "ws://relay-a.test",
    194             "ws://relay-b.test",
    195             "ws://relay-a.test",
    196         ])
    197         .expect("relay set");
    198 
    199         assert_eq!(relays, vec!["ws://relay-a.test", "ws://relay-b.test"]);
    200 
    201         assert_eq!(
    202             normalize_relay_urls(["ws://relay-c.test"]).expect("one relay"),
    203             vec!["ws://relay-c.test"]
    204         );
    205         assert_eq!(
    206             normalize_relay_urls(vec!["ws://relay-d.test".to_owned()]).expect("vec relay"),
    207             vec!["ws://relay-d.test"]
    208         );
    209     }
    210 }