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 }