lib

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

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 }