relay_hint.rs (13643B)
1 #[cfg(not(feature = "std"))] 2 use alloc::string::{String, ToString}; 3 use core::{fmt, net::Ipv6Addr, str::FromStr}; 4 5 use crate::id::ParseError; 6 7 /// One canonical, byte-stable Nostr relay hint. 8 /// 9 /// This intentionally accepts a conservative subset of WebSocket URLs. It 10 /// does not inherit browser URL normalization, legacy IPv4 syntax, Unicode 11 /// host processing, user information, or fragments. It is separate from the 12 /// generic [`crate::id::RelayUrl`] type because protocol-tag 13 /// validation must be portable across implementations. 14 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 15 pub struct NostrRelayHint(String); 16 17 impl NostrRelayHint { 18 pub fn parse(value: impl AsRef<str>) -> Result<Self, ParseError> { 19 validate_nostr_relay_hint(value.as_ref()).map(Self) 20 } 21 22 #[inline] 23 pub fn as_str(&self) -> &str { 24 self.0.as_str() 25 } 26 27 #[inline] 28 pub fn into_string(self) -> String { 29 self.0 30 } 31 } 32 33 impl AsRef<str> for NostrRelayHint { 34 #[inline] 35 fn as_ref(&self) -> &str { 36 self.as_str() 37 } 38 } 39 40 impl From<NostrRelayHint> for String { 41 #[inline] 42 fn from(value: NostrRelayHint) -> Self { 43 value.into_string() 44 } 45 } 46 47 impl fmt::Display for NostrRelayHint { 48 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 49 formatter.write_str(self.as_str()) 50 } 51 } 52 53 impl FromStr for NostrRelayHint { 54 type Err = ParseError; 55 56 fn from_str(value: &str) -> Result<Self, Self::Err> { 57 Self::parse(value) 58 } 59 } 60 61 impl TryFrom<&str> for NostrRelayHint { 62 type Error = ParseError; 63 64 fn try_from(value: &str) -> Result<Self, Self::Error> { 65 Self::parse(value) 66 } 67 } 68 69 impl TryFrom<String> for NostrRelayHint { 70 type Error = ParseError; 71 72 fn try_from(value: String) -> Result<Self, Self::Error> { 73 Self::parse(value) 74 } 75 } 76 77 fn validate_nostr_relay_hint(value: &str) -> Result<String, ParseError> { 78 if value.is_empty() { 79 return Err(ParseError::Empty); 80 } 81 if !value.bytes().all(|byte| matches!(byte, 0x21..=0x7e)) { 82 return Err(ParseError::InvalidCharacter); 83 } 84 if value.bytes().any(|byte| matches!(byte, b'#' | b'\\')) { 85 return Err(ParseError::InvalidFormat); 86 } 87 88 let remainder = value 89 .strip_prefix("wss://") 90 .or_else(|| value.strip_prefix("ws://")) 91 .ok_or(ParseError::InvalidFormat)?; 92 let authority_end = remainder 93 .bytes() 94 .position(|byte| matches!(byte, b'/' | b'?')) 95 .unwrap_or(remainder.len()); 96 let authority = &remainder[..authority_end]; 97 if authority.is_empty() 98 || authority.bytes().any(|byte| byte == b'@') 99 || matches!(remainder.as_bytes().first(), Some(b'/' | b'?')) 100 { 101 return Err(ParseError::InvalidFormat); 102 } 103 if !relay_authority_is_valid(authority) 104 || !relay_path_and_query_are_valid(&remainder[authority_end..]) 105 { 106 return Err(ParseError::InvalidFormat); 107 } 108 Ok(value.to_string()) 109 } 110 111 fn relay_authority_is_valid(authority: &str) -> bool { 112 if let Some(ipv6) = authority.strip_prefix('[') { 113 let Some(closing_index) = ipv6.find(']') else { 114 return false; 115 }; 116 let address = &ipv6[..closing_index]; 117 let suffix = &ipv6[closing_index + 1..]; 118 return canonical_ipv6(address) 119 && (suffix.is_empty() || suffix.strip_prefix(':').is_some_and(canonical_port)); 120 } 121 122 let mut parts = authority.split(':'); 123 let host = parts.next().unwrap_or_default(); 124 let port = parts.next(); 125 if parts.next().is_some() || port.is_some_and(|port| !canonical_port(port)) || host.is_empty() { 126 return false; 127 } 128 canonical_ipv4(host) || canonical_dns_host(host) 129 } 130 131 fn canonical_port(value: &str) -> bool { 132 if value.is_empty() 133 || !value.bytes().all(|byte| byte.is_ascii_digit()) 134 || value.len() > 5 135 || value.len() > 1 && value.starts_with('0') 136 { 137 return false; 138 } 139 value 140 .parse::<u32>() 141 .is_ok_and(|port| (1..=u16::MAX.into()).contains(&port)) 142 } 143 144 fn canonical_ipv4(value: &str) -> bool { 145 let mut count = 0usize; 146 for part in value.split('.') { 147 if part.is_empty() 148 || !part.bytes().all(|byte| byte.is_ascii_digit()) 149 || part.len() > 1 && part.starts_with('0') 150 || part.parse::<u8>().is_err() 151 { 152 return false; 153 } 154 count += 1; 155 } 156 count == 4 157 } 158 159 fn canonical_dns_host(value: &str) -> bool { 160 if value.len() > 253 { 161 return false; 162 } 163 let mut final_label = ""; 164 for label in value.split('.') { 165 if label.is_empty() 166 || label.len() > 63 167 || label.starts_with("xn--") 168 || !label 169 .bytes() 170 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') 171 || !label 172 .as_bytes() 173 .first() 174 .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) 175 || !label 176 .as_bytes() 177 .last() 178 .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) 179 { 180 return false; 181 } 182 final_label = label; 183 } 184 !dns_label_is_whatwg_number(final_label) 185 } 186 187 fn dns_label_is_whatwg_number(value: &str) -> bool { 188 value.bytes().all(|byte| byte.is_ascii_digit()) 189 || value 190 .strip_prefix("0x") 191 .is_some_and(|digits| digits.bytes().all(|byte| byte.is_ascii_hexdigit())) 192 } 193 194 fn canonical_ipv6(value: &str) -> bool { 195 if value.is_empty() 196 || !value 197 .bytes() 198 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f' | b':')) 199 { 200 return false; 201 } 202 let Ok(address) = value.parse::<Ipv6Addr>() else { 203 return false; 204 }; 205 canonical_ipv6_string(address) == value 206 } 207 208 fn canonical_ipv6_string(address: Ipv6Addr) -> String { 209 let segments = address.segments(); 210 let mut best_start = 0usize; 211 let mut best_len = 0usize; 212 let mut index = 0usize; 213 while index < segments.len() { 214 if segments[index] != 0 { 215 index += 1; 216 continue; 217 } 218 let start = index; 219 while index < segments.len() && segments[index] == 0 { 220 index += 1; 221 } 222 let len = index - start; 223 if len >= 2 && len > best_len { 224 best_start = start; 225 best_len = len; 226 } 227 } 228 229 let mut canonical = String::new(); 230 if best_len == 0 { 231 push_ipv6_segments(&mut canonical, &segments); 232 return canonical; 233 } 234 push_ipv6_segments(&mut canonical, &segments[..best_start]); 235 canonical.push_str("::"); 236 push_ipv6_segments(&mut canonical, &segments[best_start + best_len..]); 237 canonical 238 } 239 240 fn push_ipv6_segments(output: &mut String, segments: &[u16]) { 241 for (index, segment) in segments.iter().enumerate() { 242 if index > 0 { 243 output.push(':'); 244 } 245 push_ipv6_segment(output, *segment); 246 } 247 } 248 249 fn push_ipv6_segment(output: &mut String, segment: u16) { 250 let mut shift = 12u32; 251 while shift > 0 && segment >> shift == 0 { 252 shift -= 4; 253 } 254 loop { 255 let nibble = ((segment >> shift) & 0x0f) as u8; 256 output.push(match nibble { 257 0..=9 => (b'0' + nibble) as char, 258 _ => (b'a' + nibble - 10) as char, 259 }); 260 if shift == 0 { 261 break; 262 } 263 shift -= 4; 264 } 265 } 266 267 fn relay_path_and_query_are_valid(value: &str) -> bool { 268 if value.is_empty() { 269 return true; 270 } 271 if let Some(query) = value.strip_prefix('?') { 272 return relay_component_is_valid(query, true); 273 } 274 let Some(path) = value.strip_prefix('/') else { 275 return false; 276 }; 277 let (path, query) = path 278 .split_once('?') 279 .map_or((path, None), |(path, query)| (path, Some(query))); 280 relay_component_is_valid(path, false) 281 && query.is_none_or(|query| relay_component_is_valid(query, true)) 282 } 283 284 fn relay_component_is_valid(value: &str, query: bool) -> bool { 285 let bytes = value.as_bytes(); 286 let mut index = 0usize; 287 while index < bytes.len() { 288 if bytes[index] == b'%' { 289 if index + 2 >= bytes.len() 290 || !upper_hex_digit(bytes[index + 1]) 291 || !upper_hex_digit(bytes[index + 2]) 292 { 293 return false; 294 } 295 index += 3; 296 continue; 297 } 298 if !relay_pchar(bytes[index]) && !(query && bytes[index] == b'?') { 299 return false; 300 } 301 index += 1; 302 } 303 true 304 } 305 306 fn relay_pchar(byte: u8) -> bool { 307 byte.is_ascii_alphanumeric() 308 || matches!( 309 byte, 310 b'-' | b'.' 311 | b'_' 312 | b'~' 313 | b'!' 314 | b'$' 315 | b'&' 316 | b'\'' 317 | b'(' 318 | b')' 319 | b'*' 320 | b'+' 321 | b',' 322 | b';' 323 | b'=' 324 | b':' 325 | b'@' 326 | b'/' 327 ) 328 } 329 330 fn upper_hex_digit(byte: u8) -> bool { 331 byte.is_ascii_digit() || matches!(byte, b'A'..=b'F') 332 } 333 334 #[cfg(test)] 335 #[cfg_attr(coverage_nightly, coverage(off))] 336 mod tests { 337 use super::*; 338 use crate::id::RelayUrl; 339 340 #[test] 341 fn canonical_relay_hints_accept_portable_hosts_paths_and_queries() { 342 for value in [ 343 "wss://relay.example", 344 "ws://127.0.0.1:21003", 345 "wss://localhost", 346 "wss://[::1]", 347 "wss://[2001:db8::1]:65535/nostr/v1?region=ca-bc&next=%2Ffeed", 348 "wss://[::ffff:c000:201]", 349 "wss://relay.example:443?", 350 "wss://relay.example/a/b:@!$&'()*+,;=~_-?next=/feed??page=1", 351 ] { 352 let relay = NostrRelayHint::parse(value) 353 .unwrap_or_else(|error| panic!("{value} must be canonical: {error}")); 354 assert_eq!(relay.as_str(), value); 355 assert_eq!(relay.to_string(), value); 356 assert_eq!(value.parse::<NostrRelayHint>().expect("FromStr"), relay); 357 } 358 359 let maximum_host = format!( 360 "{}.{}.{}.{}", 361 "a".repeat(63), 362 "b".repeat(63), 363 "c".repeat(63), 364 "d".repeat(61) 365 ); 366 assert_eq!(maximum_host.len(), 253); 367 NostrRelayHint::parse(format!("wss://{maximum_host}")).expect("253-byte DNS host"); 368 } 369 370 #[test] 371 fn canonical_relay_hints_reject_normalizing_or_ambiguous_forms() { 372 assert_eq!(NostrRelayHint::parse("").unwrap_err(), ParseError::Empty); 373 for value in [ 374 "WSS://relay.example", 375 "https://relay.example", 376 "wss://", 377 "wss:///relay.example", 378 "wss:////relay.example", 379 "wss://?region=ca-bc", 380 "wss://Relay.example", 381 "wss://user@relay.example", 382 "wss://@relay.example", 383 "wss://relay.example#read", 384 "wss://relay.example\\path", 385 "wss://relay_example", 386 "wss://-relay.example", 387 "wss://relay-.example", 388 "wss://relay..example", 389 "wss://relay.example.", 390 "wss://xn--fa-hia.example", 391 "wss://example.999", 392 "wss://example.0x", 393 "wss://example.0x1", 394 "wss://%65xample.com", 395 "wss://127.1", 396 "wss://2130706433", 397 "wss://0x7f.1", 398 "wss://01.2.3.4", 399 "wss://256.1.1.1", 400 "wss://[2001:DB8::1]", 401 "wss://[2001:0db8::1]", 402 "wss://[2001:db8:0:0:0:0:0:1]", 403 "wss://[2001:0:0:1::1:1]", 404 "wss://[::ffff:192.0.2.1]", 405 "wss://[fe80::1%25en0]", 406 "wss://[v1.foo]", 407 "wss://2001:db8::1", 408 "wss://relay.example:", 409 "wss://relay.example:0", 410 "wss://relay.example:01", 411 "wss://relay.example:+443", 412 "wss://relay.example:65536", 413 "wss://relay.example/[raw]", 414 "wss://relay.example/%", 415 "wss://relay.example/%2", 416 "wss://relay.example/%2f", 417 "wss://relay.example/%GG", 418 ] { 419 assert_eq!( 420 NostrRelayHint::parse(value).unwrap_err(), 421 ParseError::InvalidFormat, 422 "{value}" 423 ); 424 } 425 426 for value in [ 427 "wss://relay.example path", 428 "wss://relay.example/\u{007f}", 429 "wss://relay.example/é", 430 ] { 431 assert_eq!( 432 NostrRelayHint::parse(value).unwrap_err(), 433 ParseError::InvalidCharacter, 434 "{value}" 435 ); 436 } 437 438 let label_too_long = "a".repeat(64); 439 assert_eq!( 440 NostrRelayHint::parse(format!("wss://{label_too_long}.example")).unwrap_err(), 441 ParseError::InvalidFormat 442 ); 443 let host_too_long = format!( 444 "{}.{}.{}.{}", 445 "a".repeat(63), 446 "b".repeat(63), 447 "c".repeat(63), 448 "d".repeat(62) 449 ); 450 assert_eq!(host_too_long.len(), 254); 451 assert_eq!( 452 NostrRelayHint::parse(format!("wss://{host_too_long}")).unwrap_err(), 453 ParseError::InvalidFormat 454 ); 455 } 456 457 #[test] 458 fn generic_relay_url_remains_a_distinct_surface() { 459 assert!(RelayUrl::parse("wss://Relay.Example").is_ok()); 460 assert!(NostrRelayHint::parse("wss://Relay.Example").is_err()); 461 } 462 }