lib

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

target.rs (27996B)


      1 //! Extensible transport identities and canonical operation targets.
      2 
      3 use crate::{
      4     Error as TransportError, TransportId,
      5     endpoint::{ENDPOINT_URI_MAX_BYTES, TARGET_LABEL_MAX_BYTES, TARGET_SCOPE_MAX_BYTES},
      6 };
      7 use alloc::collections::BTreeSet;
      8 use alloc::format;
      9 use alloc::string::{String, ToString};
     10 use alloc::vec::Vec;
     11 use core::net::{IpAddr, Ipv6Addr};
     12 use core::str::FromStr;
     13 use sha2::{Digest, Sha256};
     14 
     15 /// Maximum number of targets in one operation.
     16 pub const TARGET_SET_MAX_ITEMS: usize = 64;
     17 
     18 /// Construction policy for canonical Nostr relay targets.
     19 ///
     20 /// This policy controls only whether a cleartext relay identifier may be
     21 /// represented. A concrete adapter must independently retain and enforce its
     22 /// network policy before and after address resolution.
     23 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     24 #[non_exhaustive]
     25 pub enum TargetNetworkPolicy {
     26     /// TLS relay targets plus exact loopback cleartext targets.
     27     TlsOrLoopback,
     28     /// Exact RFC1918 IPv4 or ULA IPv6 device targets, with or without TLS.
     29     PrivateDevice,
     30 }
     31 
     32 #[cfg_attr(feature = "serde", derive(serde::Serialize))]
     33 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
     34 /// Canonical transport endpoint URI.
     35 pub struct EndpointUri(String);
     36 
     37 impl EndpointUri {
     38     pub fn parse(raw: impl AsRef<str>) -> Result<Self, TransportError> {
     39         let canonical = canonicalize_uri(raw.as_ref())?;
     40         Ok(Self(canonical))
     41     }
     42 
     43     fn parse_nostr_relay(
     44         raw: impl AsRef<str>,
     45         policy: TargetNetworkPolicy,
     46     ) -> Result<Self, TransportError> {
     47         let canonical = canonicalize_nostr_relay_uri(raw.as_ref(), policy)?;
     48         Ok(Self(canonical))
     49     }
     50 
     51     pub fn as_str(&self) -> &str {
     52         &self.0
     53     }
     54 }
     55 
     56 impl core::fmt::Display for EndpointUri {
     57     fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
     58         f.write_str(&self.0)
     59     }
     60 }
     61 
     62 impl AsRef<str> for EndpointUri {
     63     fn as_ref(&self) -> &str {
     64         self.as_str()
     65     }
     66 }
     67 
     68 impl FromStr for EndpointUri {
     69     type Err = TransportError;
     70 
     71     fn from_str(value: &str) -> Result<Self, Self::Err> {
     72         Self::parse(value)
     73     }
     74 }
     75 
     76 impl TryFrom<&str> for EndpointUri {
     77     type Error = TransportError;
     78 
     79     fn try_from(value: &str) -> Result<Self, Self::Error> {
     80         Self::parse(value)
     81     }
     82 }
     83 
     84 #[cfg(feature = "serde")]
     85 impl<'de> serde::Deserialize<'de> for EndpointUri {
     86     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     87     where
     88         D: serde::Deserializer<'de>,
     89     {
     90         let raw = <String as serde::Deserialize>::deserialize(deserializer)?;
     91         let parsed = Self::parse(raw.as_str()).map_err(serde::de::Error::custom)?;
     92         if parsed.as_str() != raw {
     93             return Err(serde::de::Error::custom(
     94                 "transport target URI is not canonical",
     95             ));
     96         }
     97         Ok(parsed)
     98     }
     99 }
    100 
    101 #[cfg_attr(feature = "serde", derive(serde::Serialize))]
    102 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    103 /// Optional transport-neutral target scope.
    104 pub struct TargetScope(String);
    105 
    106 impl TargetScope {
    107     pub fn parse(raw: impl AsRef<str>) -> Result<Self, TransportError> {
    108         let value = raw.as_ref();
    109         if value.is_empty() {
    110             return Err(TransportError::EmptyTargetScope);
    111         }
    112         if value.len() > TARGET_SCOPE_MAX_BYTES
    113             || value != value.trim()
    114             || value
    115                 .chars()
    116                 .any(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')))
    117         {
    118             return Err(TransportError::InvalidTargetScope);
    119         }
    120         Ok(Self(value.to_string()))
    121     }
    122 
    123     pub fn as_str(&self) -> &str {
    124         &self.0
    125     }
    126 }
    127 
    128 impl core::fmt::Display for TargetScope {
    129     fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
    130         f.write_str(&self.0)
    131     }
    132 }
    133 
    134 impl AsRef<str> for TargetScope {
    135     fn as_ref(&self) -> &str {
    136         self.as_str()
    137     }
    138 }
    139 
    140 impl FromStr for TargetScope {
    141     type Err = TransportError;
    142 
    143     fn from_str(value: &str) -> Result<Self, Self::Err> {
    144         Self::parse(value)
    145     }
    146 }
    147 
    148 impl TryFrom<&str> for TargetScope {
    149     type Error = TransportError;
    150 
    151     fn try_from(value: &str) -> Result<Self, Self::Error> {
    152         Self::parse(value)
    153     }
    154 }
    155 
    156 #[cfg(feature = "serde")]
    157 impl<'de> serde::Deserialize<'de> for TargetScope {
    158     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    159     where
    160         D: serde::Deserializer<'de>,
    161     {
    162         let raw = <String as serde::Deserialize>::deserialize(deserializer)?;
    163         Self::parse(raw).map_err(serde::de::Error::custom)
    164     }
    165 }
    166 
    167 #[cfg_attr(feature = "serde", derive(serde::Serialize))]
    168 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    169 /// Optional human-readable target label excluded from target identity.
    170 pub struct TargetLabel(String);
    171 
    172 impl TargetLabel {
    173     pub fn parse(raw: impl AsRef<str>) -> Result<Self, TransportError> {
    174         let raw = raw.as_ref();
    175         let trimmed = raw.trim();
    176         if trimmed.is_empty() {
    177             return Err(TransportError::EmptyTargetLabel);
    178         }
    179         if raw.len() > TARGET_LABEL_MAX_BYTES || trimmed.chars().any(char::is_control) {
    180             return Err(TransportError::InvalidTargetLabel);
    181         }
    182         Ok(Self(trimmed.to_string()))
    183     }
    184 
    185     pub fn as_str(&self) -> &str {
    186         &self.0
    187     }
    188 }
    189 
    190 impl core::fmt::Display for TargetLabel {
    191     fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
    192         f.write_str(&self.0)
    193     }
    194 }
    195 
    196 impl AsRef<str> for TargetLabel {
    197     fn as_ref(&self) -> &str {
    198         self.as_str()
    199     }
    200 }
    201 
    202 impl FromStr for TargetLabel {
    203     type Err = TransportError;
    204 
    205     fn from_str(value: &str) -> Result<Self, Self::Err> {
    206         Self::parse(value)
    207     }
    208 }
    209 
    210 impl TryFrom<&str> for TargetLabel {
    211     type Error = TransportError;
    212 
    213     fn try_from(value: &str) -> Result<Self, Self::Error> {
    214         Self::parse(value)
    215     }
    216 }
    217 
    218 #[cfg(feature = "serde")]
    219 impl<'de> serde::Deserialize<'de> for TargetLabel {
    220     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    221     where
    222         D: serde::Deserializer<'de>,
    223     {
    224         let raw = <String as serde::Deserialize>::deserialize(deserializer)?;
    225         let parsed = Self::parse(raw.as_str()).map_err(serde::de::Error::custom)?;
    226         if parsed.as_str() != raw {
    227             return Err(serde::de::Error::custom(
    228                 "transport target label is not canonical",
    229             ));
    230         }
    231         Ok(parsed)
    232     }
    233 }
    234 
    235 #[cfg_attr(feature = "serde", derive(serde::Serialize))]
    236 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    237 /// Deterministic SHA-256 fingerprint of canonical target identity fields.
    238 pub struct TargetFingerprint(String);
    239 
    240 impl TargetFingerprint {
    241     pub fn from_target(kind: &TransportId, uri: &EndpointUri, scope: Option<&TargetScope>) -> Self {
    242         let mut hasher = Sha256::new();
    243         hasher.update(kind.canonical_label().as_bytes());
    244         hasher.update([0]);
    245         hasher.update(uri.as_str().as_bytes());
    246         if let Some(scope) = scope {
    247             hasher.update([0]);
    248             hasher.update(scope.as_str().as_bytes());
    249         }
    250         let digest = hasher.finalize();
    251         Self(hex_encode(&digest))
    252     }
    253 
    254     pub fn parse(raw: impl AsRef<str>) -> Result<Self, TransportError> {
    255         let raw = raw.as_ref();
    256         if raw.len() != 64 || !raw.bytes().all(|byte| byte.is_ascii_hexdigit()) {
    257             return Err(TransportError::InvalidTargetFingerprint);
    258         }
    259         Ok(Self(raw.to_ascii_lowercase()))
    260     }
    261 
    262     pub fn as_str(&self) -> &str {
    263         &self.0
    264     }
    265 }
    266 
    267 fn hex_encode(bytes: &[u8]) -> String {
    268     const HEX: &[u8; 16] = b"0123456789abcdef";
    269     let mut out = String::with_capacity(bytes.len() * 2);
    270     for byte in bytes {
    271         out.push(HEX[(byte >> 4) as usize] as char);
    272         out.push(HEX[(byte & 0x0f) as usize] as char);
    273     }
    274     out
    275 }
    276 
    277 impl core::fmt::Display for TargetFingerprint {
    278     fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
    279         f.write_str(&self.0)
    280     }
    281 }
    282 
    283 impl AsRef<str> for TargetFingerprint {
    284     fn as_ref(&self) -> &str {
    285         self.as_str()
    286     }
    287 }
    288 
    289 impl FromStr for TargetFingerprint {
    290     type Err = TransportError;
    291 
    292     fn from_str(value: &str) -> Result<Self, Self::Err> {
    293         Self::parse(value)
    294     }
    295 }
    296 
    297 impl TryFrom<&str> for TargetFingerprint {
    298     type Error = TransportError;
    299 
    300     fn try_from(value: &str) -> Result<Self, Self::Error> {
    301         Self::parse(value)
    302     }
    303 }
    304 
    305 #[cfg(feature = "serde")]
    306 impl<'de> serde::Deserialize<'de> for TargetFingerprint {
    307     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    308     where
    309         D: serde::Deserializer<'de>,
    310     {
    311         let raw = <String as serde::Deserialize>::deserialize(deserializer)?;
    312         let parsed = Self::parse(raw.as_str()).map_err(serde::de::Error::custom)?;
    313         if parsed.as_str() != raw {
    314             return Err(serde::de::Error::custom(
    315                 "transport target fingerprint is not canonical",
    316             ));
    317         }
    318         Ok(parsed)
    319     }
    320 }
    321 
    322 #[cfg_attr(feature = "serde", derive(serde::Serialize))]
    323 #[derive(Clone, Debug, PartialEq, Eq)]
    324 /// Validated transport-neutral target.
    325 pub struct Target {
    326     kind: TransportId,
    327     uri: EndpointUri,
    328     scope: Option<TargetScope>,
    329     label: Option<TargetLabel>,
    330     fingerprint: TargetFingerprint,
    331     #[cfg_attr(
    332         feature = "serde",
    333         serde(skip_serializing_if = "private_device_cleartext_is_false")
    334     )]
    335     private_device_cleartext: bool,
    336 }
    337 
    338 impl Target {
    339     pub fn new(kind: TransportId, uri: impl AsRef<str>) -> Result<Self, TransportError> {
    340         Self::new_with_metadata(kind, uri, None, None)
    341     }
    342 
    343     pub fn nostr_relay(uri: impl AsRef<str>) -> Result<Self, TransportError> {
    344         Self::nostr_relay_with_metadata(uri, None, None)
    345     }
    346 
    347     /// Creates a Nostr relay target under an explicit construction policy.
    348     ///
    349     /// The private-device policy accepts only literal RFC1918 IPv4 or ULA IPv6
    350     /// destinations. Named, public, loopback, link-local, unspecified, and
    351     /// multicast destinations remain denied.
    352     pub fn nostr_relay_with_policy(
    353         uri: impl AsRef<str>,
    354         policy: TargetNetworkPolicy,
    355     ) -> Result<Self, TransportError> {
    356         Self::new_with_metadata_and_nostr_policy(uri, None, None, policy)
    357     }
    358 
    359     pub fn nostr_relay_with_metadata(
    360         uri: impl AsRef<str>,
    361         scope: Option<TargetScope>,
    362         label: Option<TargetLabel>,
    363     ) -> Result<Self, TransportError> {
    364         Self::new_with_metadata_and_nostr_policy(
    365             uri,
    366             scope,
    367             label,
    368             TargetNetworkPolicy::TlsOrLoopback,
    369         )
    370     }
    371 
    372     pub fn local(uri: impl AsRef<str>) -> Result<Self, TransportError> {
    373         Self::local_with_metadata(uri, None, None)
    374     }
    375 
    376     pub fn local_with_metadata(
    377         uri: impl AsRef<str>,
    378         scope: Option<TargetScope>,
    379         label: Option<TargetLabel>,
    380     ) -> Result<Self, TransportError> {
    381         Self::new_with_metadata(TransportId::LOCAL, uri, scope, label)
    382     }
    383 
    384     pub fn new_with_metadata(
    385         kind: TransportId,
    386         uri: impl AsRef<str>,
    387         scope: Option<TargetScope>,
    388         label: Option<TargetLabel>,
    389     ) -> Result<Self, TransportError> {
    390         if kind == TransportId::NOSTR {
    391             return Self::new_with_metadata_and_nostr_policy(
    392                 uri,
    393                 scope,
    394                 label,
    395                 TargetNetworkPolicy::TlsOrLoopback,
    396             );
    397         }
    398         let uri = EndpointUri::parse(uri)?;
    399         let fingerprint = TargetFingerprint::from_target(&kind, &uri, scope.as_ref());
    400         Ok(Self {
    401             kind,
    402             uri,
    403             scope,
    404             label,
    405             fingerprint,
    406             private_device_cleartext: false,
    407         })
    408     }
    409 
    410     fn new_with_metadata_and_nostr_policy(
    411         uri: impl AsRef<str>,
    412         scope: Option<TargetScope>,
    413         label: Option<TargetLabel>,
    414         policy: TargetNetworkPolicy,
    415     ) -> Result<Self, TransportError> {
    416         let uri = EndpointUri::parse_nostr_relay(uri, policy)?;
    417         let fingerprint = TargetFingerprint::from_target(&TransportId::NOSTR, &uri, scope.as_ref());
    418         Ok(Self {
    419             kind: TransportId::NOSTR,
    420             private_device_cleartext: policy == TargetNetworkPolicy::PrivateDevice
    421                 && uri.as_str().starts_with("ws://"),
    422             uri,
    423             scope,
    424             label,
    425             fingerprint,
    426         })
    427     }
    428 
    429     pub fn kind(&self) -> &TransportId {
    430         &self.kind
    431     }
    432 
    433     pub fn uri(&self) -> &EndpointUri {
    434         &self.uri
    435     }
    436 
    437     pub fn scope(&self) -> Option<&TargetScope> {
    438         self.scope.as_ref()
    439     }
    440 
    441     pub fn label(&self) -> Option<&TargetLabel> {
    442         self.label.as_ref()
    443     }
    444 
    445     pub fn fingerprint(&self) -> &TargetFingerprint {
    446         &self.fingerprint
    447     }
    448 }
    449 
    450 #[cfg(feature = "serde")]
    451 #[derive(serde::Deserialize)]
    452 #[serde(deny_unknown_fields)]
    453 struct TargetWire {
    454     kind: TransportId,
    455     uri: String,
    456     scope: Option<String>,
    457     label: Option<String>,
    458     fingerprint: String,
    459     private_device_cleartext: Option<bool>,
    460 }
    461 
    462 #[cfg(feature = "serde")]
    463 impl<'de> serde::Deserialize<'de> for Target {
    464     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    465     where
    466         D: serde::Deserializer<'de>,
    467     {
    468         let wire = TargetWire::deserialize(deserializer)?;
    469         let scope = wire
    470             .scope
    471             .map(TargetScope::parse)
    472             .transpose()
    473             .map_err(serde::de::Error::custom)?;
    474         let label = wire
    475             .label
    476             .map(|label| {
    477                 let parsed = TargetLabel::parse(label.as_str())?;
    478                 if parsed.as_str() != label {
    479                     return Err(TransportError::InvalidTargetLabel);
    480                 }
    481                 Ok(parsed)
    482             })
    483             .transpose()
    484             .map_err(serde::de::Error::custom)?;
    485         let fingerprint = TargetFingerprint::parse(wire.fingerprint.as_str())
    486             .map_err(serde::de::Error::custom)?;
    487         if fingerprint.as_str() != wire.fingerprint {
    488             return Err(serde::de::Error::custom(
    489                 "transport target fingerprint is not canonical",
    490             ));
    491         }
    492         if wire.private_device_cleartext == Some(false) {
    493             return Err(serde::de::Error::custom(
    494                 "transport target private-device marker is not canonical",
    495             ));
    496         }
    497         let target =
    498             if wire.kind == TransportId::NOSTR && wire.private_device_cleartext == Some(true) {
    499                 Self::new_with_metadata_and_nostr_policy(
    500                     wire.uri.as_str(),
    501                     scope.clone(),
    502                     label.clone(),
    503                     TargetNetworkPolicy::PrivateDevice,
    504                 )
    505             } else {
    506                 Self::new_with_metadata(wire.kind, wire.uri.as_str(), scope.clone(), label.clone())
    507             }
    508             .map_err(serde::de::Error::custom)?;
    509         if target.uri.as_str() != wire.uri
    510             || target.scope != scope
    511             || target.label != label
    512             || target.fingerprint != fingerprint
    513         {
    514             return Err(serde::de::Error::custom(
    515                 "transport target identity does not match its canonical fields",
    516             ));
    517         }
    518         Ok(target)
    519     }
    520 }
    521 
    522 #[cfg_attr(feature = "serde", derive(serde::Serialize))]
    523 #[derive(Clone, Debug, PartialEq, Eq)]
    524 /// Bounded non-empty set of targets with unique fingerprints.
    525 pub struct TargetSet {
    526     targets: Vec<Target>,
    527 }
    528 
    529 impl TargetSet {
    530     pub fn new(targets: Vec<Target>) -> Result<Self, TransportError> {
    531         if targets.is_empty() {
    532             return Err(TransportError::EmptyTargetSet);
    533         }
    534         if targets.len() > TARGET_SET_MAX_ITEMS {
    535             return Err(TransportError::TargetSetTooLarge);
    536         }
    537         let mut fingerprints = BTreeSet::new();
    538         for target in &targets {
    539             if !fingerprints.insert(target.fingerprint.as_str().to_string()) {
    540                 return Err(TransportError::DuplicateTargetFingerprint);
    541             }
    542         }
    543         Ok(Self { targets })
    544     }
    545 
    546     pub fn targets(&self) -> &[Target] {
    547         &self.targets
    548     }
    549 
    550     /// Returns whether this set contains the exact target fingerprint.
    551     pub fn contains(&self, fingerprint: &TargetFingerprint) -> bool {
    552         self.targets
    553             .iter()
    554             .any(|target| target.fingerprint() == fingerprint)
    555     }
    556 
    557     pub fn len(&self) -> usize {
    558         self.targets.len()
    559     }
    560 
    561     pub fn is_empty(&self) -> bool {
    562         self.targets.is_empty()
    563     }
    564 }
    565 
    566 #[cfg(feature = "serde")]
    567 #[derive(serde::Deserialize)]
    568 #[serde(deny_unknown_fields)]
    569 struct TargetSetWire {
    570     targets: Vec<Target>,
    571 }
    572 
    573 #[cfg(feature = "serde")]
    574 impl<'de> serde::Deserialize<'de> for TargetSet {
    575     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    576     where
    577         D: serde::Deserializer<'de>,
    578     {
    579         let wire = TargetSetWire::deserialize(deserializer)?;
    580         Self::new(wire.targets).map_err(serde::de::Error::custom)
    581     }
    582 }
    583 
    584 fn canonicalize_uri(raw: &str) -> Result<String, TransportError> {
    585     let trimmed = raw.trim();
    586     if trimmed.is_empty() {
    587         return Err(TransportError::EmptyTargetUri);
    588     }
    589     if raw != trimmed {
    590         return Err(TransportError::InvalidTargetUri);
    591     }
    592     if trimmed.len() > ENDPOINT_URI_MAX_BYTES {
    593         return Err(TransportError::InvalidTargetUri);
    594     }
    595     if trimmed
    596         .chars()
    597         .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
    598     {
    599         return Err(TransportError::InvalidTargetUri);
    600     }
    601     if let Some(colon) = trimmed.find(':') {
    602         let scheme = &trimmed[..colon];
    603         if !is_valid_scheme(scheme) {
    604             return Err(TransportError::InvalidTargetUri);
    605         }
    606         let rest = &trimmed[colon + 1..];
    607         if let Some(authority_rest) = rest.strip_prefix("//") {
    608             let authority_end = authority_rest
    609                 .find(['/', '?', '#'])
    610                 .unwrap_or(authority_rest.len());
    611             let authority = &authority_rest[..authority_end];
    612             let suffix = &authority_rest[authority_end..];
    613             return Ok(format!(
    614                 "{}://{}{}",
    615                 scheme.to_ascii_lowercase(),
    616                 authority.to_ascii_lowercase(),
    617                 suffix
    618             ));
    619         }
    620         return Ok(format!("{}:{rest}", scheme.to_ascii_lowercase()));
    621     }
    622     Ok(trimmed.to_string())
    623 }
    624 
    625 fn is_valid_scheme(value: &str) -> bool {
    626     let mut chars = value.chars();
    627     matches!(chars.next(), Some(first) if first.is_ascii_alphabetic())
    628         && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'))
    629 }
    630 
    631 fn canonicalize_nostr_relay_uri(
    632     raw: &str,
    633     policy: TargetNetworkPolicy,
    634 ) -> Result<String, TransportError> {
    635     let trimmed = raw.trim();
    636     if trimmed.is_empty() {
    637         return Err(TransportError::EmptyTargetUri);
    638     }
    639     if raw != trimmed {
    640         return Err(TransportError::InvalidTargetUri);
    641     }
    642     if trimmed.len() > ENDPOINT_URI_MAX_BYTES {
    643         return Err(TransportError::InvalidTargetUri);
    644     }
    645     if trimmed
    646         .chars()
    647         .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
    648     {
    649         return Err(TransportError::InvalidTargetUri);
    650     }
    651     if trimmed.contains('?') || trimmed.contains('#') || trimmed.contains('\\') {
    652         return Err(TransportError::InvalidTargetUri);
    653     }
    654     let Some(scheme_end) = trimmed.find("://") else {
    655         return Err(TransportError::InvalidTargetUri);
    656     };
    657     let scheme = trimmed[..scheme_end].to_ascii_lowercase();
    658     if !matches!(scheme.as_str(), "wss" | "ws") {
    659         return Err(TransportError::InvalidTargetUri);
    660     }
    661     let endpoint = &trimmed[scheme_end + 3..];
    662     let authority_end = endpoint.find('/').unwrap_or(endpoint.len());
    663     let authority = &endpoint[..authority_end];
    664     let path = &endpoint[authority_end..];
    665     let authority = canonicalize_nostr_relay_authority(authority, scheme.as_str(), policy)?;
    666     validate_nostr_relay_path(path)?;
    667     if path == "/" {
    668         return Ok(format!("{scheme}://{authority}"));
    669     }
    670     Ok(format!("{scheme}://{authority}{path}"))
    671 }
    672 
    673 fn canonicalize_nostr_relay_authority(
    674     authority: &str,
    675     scheme: &str,
    676     policy: TargetNetworkPolicy,
    677 ) -> Result<String, TransportError> {
    678     if authority.is_empty() || authority.contains('@') {
    679         return Err(TransportError::InvalidTargetUri);
    680     }
    681     let (host, port) = if let Some(rest) = authority.strip_prefix('[') {
    682         let Some(host_end) = rest.find(']') else {
    683             return Err(TransportError::InvalidTargetUri);
    684         };
    685         let host = &rest[..host_end];
    686         let suffix = &rest[host_end + 1..];
    687         if host.is_empty()
    688             || host
    689                 .chars()
    690                 .any(|ch| matches!(ch, '[' | ']' | '/' | '?' | '#' | '@' | '\\'))
    691         {
    692             return Err(TransportError::InvalidTargetUri);
    693         }
    694         let canonical_host = canonicalize_nostr_relay_ipv6(host)?;
    695         (
    696             format!("[{canonical_host}]"),
    697             parse_nostr_relay_port(suffix)?,
    698         )
    699     } else {
    700         if authority.contains(['[', ']', '\\']) {
    701             return Err(TransportError::InvalidTargetUri);
    702         }
    703         let mut parts = authority.splitn(2, ':');
    704         let host = parts.next().unwrap_or_default();
    705         let port = parts
    706             .next()
    707             .map(parse_nostr_relay_port_with_prefix)
    708             .transpose()?;
    709         (canonicalize_nostr_relay_host(host)?, port)
    710     };
    711     match policy {
    712         TargetNetworkPolicy::TlsOrLoopback => {
    713             if scheme == "ws" && !is_local_ws_relay_host(host.as_str()) {
    714                 return Err(TransportError::InvalidTargetUri);
    715             }
    716         }
    717         TargetNetworkPolicy::PrivateDevice => {
    718             if !is_private_device_relay_host(host.as_str()) {
    719                 return Err(TransportError::InvalidTargetUri);
    720             }
    721         }
    722     }
    723     let port =
    724         port.filter(|port| !matches!((scheme, port.as_str()), ("wss", "443") | ("ws", "80")));
    725     Ok(match port {
    726         Some(port) => format!("{host}:{port}"),
    727         None => host,
    728     })
    729 }
    730 
    731 fn parse_nostr_relay_port(suffix: &str) -> Result<Option<String>, TransportError> {
    732     if suffix.is_empty() {
    733         return Ok(None);
    734     }
    735     let Some(port) = suffix.strip_prefix(':') else {
    736         return Err(TransportError::InvalidTargetUri);
    737     };
    738     parse_nostr_relay_port_with_prefix(port).map(Some)
    739 }
    740 
    741 fn parse_nostr_relay_port_with_prefix(port: &str) -> Result<String, TransportError> {
    742     if port.is_empty()
    743         || !port.bytes().all(|byte| byte.is_ascii_digit())
    744         || port.len() > 1 && port.starts_with('0')
    745     {
    746         return Err(TransportError::InvalidTargetUri);
    747     }
    748     let value = port
    749         .parse::<u32>()
    750         .map_err(|_| TransportError::InvalidTargetUri)?;
    751     if !(1..=u16::MAX as u32).contains(&value) {
    752         return Err(TransportError::InvalidTargetUri);
    753     }
    754     Ok(port.to_string())
    755 }
    756 
    757 fn canonicalize_nostr_relay_host(host: &str) -> Result<String, TransportError> {
    758     let canonical = host.to_ascii_lowercase();
    759     if canonical_ipv4(canonical.as_str()) || canonical_dns_host(canonical.as_str()) {
    760         return Ok(canonical);
    761     }
    762     Err(TransportError::InvalidTargetUri)
    763 }
    764 
    765 fn canonicalize_nostr_relay_ipv6(host: &str) -> Result<String, TransportError> {
    766     if host.is_empty()
    767         || !host
    768             .bytes()
    769             .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f' | b'A'..=b'F' | b':'))
    770     {
    771         return Err(TransportError::InvalidTargetUri);
    772     }
    773     host.parse::<Ipv6Addr>()
    774         .map(|address| address.to_string())
    775         .map_err(|_| TransportError::InvalidTargetUri)
    776 }
    777 
    778 fn canonical_ipv4(value: &str) -> bool {
    779     let mut count = 0usize;
    780     for part in value.split('.') {
    781         if part.is_empty()
    782             || !part.bytes().all(|byte| byte.is_ascii_digit())
    783             || part.len() > 1 && part.starts_with('0')
    784             || part.parse::<u8>().is_err()
    785         {
    786             return false;
    787         }
    788         count += 1;
    789     }
    790     count == 4
    791 }
    792 
    793 fn canonical_dns_host(value: &str) -> bool {
    794     if value.is_empty() || value.len() > 253 {
    795         return false;
    796     }
    797     let mut final_label = "";
    798     for label in value.split('.') {
    799         if label.is_empty()
    800             || label.len() > 63
    801             || label.starts_with("xn--")
    802             || !label
    803                 .bytes()
    804                 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
    805             || !label
    806                 .as_bytes()
    807                 .first()
    808                 .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
    809             || !label
    810                 .as_bytes()
    811                 .last()
    812                 .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
    813         {
    814             return false;
    815         }
    816         final_label = label;
    817     }
    818     !dns_label_is_whatwg_number(final_label)
    819 }
    820 
    821 fn dns_label_is_whatwg_number(value: &str) -> bool {
    822     value.bytes().all(|byte| byte.is_ascii_digit())
    823         || value
    824             .strip_prefix("0x")
    825             .is_some_and(|digits| digits.bytes().all(|byte| byte.is_ascii_hexdigit()))
    826 }
    827 
    828 fn validate_nostr_relay_path(path: &str) -> Result<(), TransportError> {
    829     if path.is_empty() || path == "/" {
    830         return Ok(());
    831     }
    832     let Some(component) = path.strip_prefix('/') else {
    833         return Err(TransportError::InvalidTargetUri);
    834     };
    835     if relay_path_component_is_valid(component) {
    836         Ok(())
    837     } else {
    838         Err(TransportError::InvalidTargetUri)
    839     }
    840 }
    841 
    842 fn relay_path_component_is_valid(value: &str) -> bool {
    843     if value.split('/').any(relay_path_segment_is_dot) {
    844         return false;
    845     }
    846     let bytes = value.as_bytes();
    847     let mut index = 0usize;
    848     while index < bytes.len() {
    849         if bytes[index] == b'%' {
    850             if index + 2 >= bytes.len()
    851                 || !upper_hex_digit(bytes[index + 1])
    852                 || !upper_hex_digit(bytes[index + 2])
    853             {
    854                 return false;
    855             }
    856             index += 3;
    857             continue;
    858         }
    859         if !relay_path_character(bytes[index]) {
    860             return false;
    861         }
    862         index += 1;
    863     }
    864     true
    865 }
    866 
    867 fn relay_path_segment_is_dot(segment: &str) -> bool {
    868     let bytes = segment.as_bytes();
    869     let mut index = 0usize;
    870     let mut dots = 0usize;
    871     while index < bytes.len() {
    872         if bytes[index] == b'.' {
    873             dots += 1;
    874             index += 1;
    875         } else if bytes[index..].starts_with(b"%2E") {
    876             dots += 1;
    877             index += 3;
    878         } else {
    879             return false;
    880         }
    881     }
    882     matches!(dots, 1 | 2)
    883 }
    884 
    885 fn relay_path_character(byte: u8) -> bool {
    886     byte.is_ascii_alphanumeric()
    887         || matches!(
    888             byte,
    889             b'-' | b'.'
    890                 | b'_'
    891                 | b'~'
    892                 | b'!'
    893                 | b'$'
    894                 | b'&'
    895                 | b'\''
    896                 | b'('
    897                 | b')'
    898                 | b'*'
    899                 | b'+'
    900                 | b','
    901                 | b';'
    902                 | b'='
    903                 | b':'
    904                 | b'@'
    905                 | b'/'
    906         )
    907 }
    908 
    909 fn upper_hex_digit(byte: u8) -> bool {
    910     byte.is_ascii_digit() || matches!(byte, b'A'..=b'F')
    911 }
    912 
    913 fn is_local_ws_relay_host(host: &str) -> bool {
    914     matches!(host, "localhost" | "127.0.0.1" | "[::1]")
    915 }
    916 
    917 fn is_private_device_relay_host(host: &str) -> bool {
    918     match host.trim_matches(['[', ']']).parse::<IpAddr>() {
    919         Ok(IpAddr::V4(address)) => address.is_private(),
    920         Ok(IpAddr::V6(address)) => address.segments()[0] & 0xfe00 == 0xfc00,
    921         Err(_) => false,
    922     }
    923 }
    924 
    925 #[cfg(feature = "serde")]
    926 const fn private_device_cleartext_is_false(value: &bool) -> bool {
    927     !*value
    928 }