lib

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

url.rs (15014B)


      1 //! Structural Blossom blob URLs and the Radroots-approved reference state.
      2 //!
      3 //! [`BlobUrl`] preserves received HTTP or HTTPS references after strict
      4 //! structural parsing. [`BlobUrl::approve`] produces [`ApprovedBlobUrl`] only
      5 //! for HTTPS and loopback HTTP references. Approval permits a caller to consider
      6 //! the reference for transport; it does not perform a request or establish host
      7 //! reputation, byte integrity, authenticity, or application media safety.
      8 
      9 use alloc::string::String;
     10 use alloc::string::ToString;
     11 use core::{fmt, str::FromStr};
     12 use unicode_general_category::{GeneralCategory, get_general_category};
     13 use url_nostd::{Host, Url};
     14 
     15 use crate::{error::Error, hash::HashPath};
     16 
     17 #[derive(Clone, Debug, PartialEq, Eq, Hash)]
     18 pub struct BlobUrl {
     19     url: Url,
     20     hash_path: HashPath,
     21 }
     22 
     23 impl BlobUrl {
     24     pub fn parse(value: &str) -> Result<Self, Error> {
     25         if !value.contains("://") || !raw_url_text_is_valid(value) {
     26             return Err(Error::InvalidBlobUrl);
     27         }
     28         let url = Url::parse(value).map_err(|_| Error::InvalidBlobUrl)?;
     29         if url.scheme() != "http" && url.scheme() != "https" {
     30             return Err(Error::UnsupportedBlobUrlScheme);
     31         }
     32         if raw_authority(value).contains('@') {
     33             return Err(Error::BlobUrlCredentialsForbidden);
     34         }
     35         if url.query().is_some() {
     36             return Err(Error::BlobUrlQueryForbidden);
     37         }
     38         if url.fragment().is_some() {
     39             return Err(Error::BlobUrlFragmentForbidden);
     40         }
     41         if value.contains('\\')
     42             || value.contains('%')
     43             || value.contains("/./")
     44             || value.contains("/../")
     45         {
     46             return Err(Error::InvalidBlobUrl);
     47         }
     48         validate_authority(value, &url)?;
     49         let hash_path = HashPath::parse(url.path())?;
     50         Ok(Self { url, hash_path })
     51     }
     52 
     53     pub fn as_str(&self) -> &str {
     54         self.url.as_str()
     55     }
     56 
     57     /// Returns the BUD-02 upload URL at this blob's exact origin.
     58     ///
     59     /// This is a structural projection, not transport authorization. Callers
     60     /// must still enforce endpoint policy and bind the upload to exact bytes.
     61     /// The canonical blob reference remains unchanged for retrieval verification.
     62     pub fn upload_url(&self) -> String {
     63         let mut url = self.url.clone();
     64         url.set_path("/upload");
     65         url.to_string()
     66     }
     67 
     68     pub fn scheme(&self) -> &str {
     69         self.url.scheme()
     70     }
     71 
     72     pub fn host(&self) -> &str {
     73         self.url
     74             .host_str()
     75             .expect("validated HTTP and HTTPS URLs always have a host")
     76     }
     77 
     78     pub fn port(&self) -> Option<u16> {
     79         self.url.port()
     80     }
     81 
     82     pub fn hash_path(&self) -> &HashPath {
     83         &self.hash_path
     84     }
     85 
     86     pub fn is_https(&self) -> bool {
     87         self.url.scheme() == "https"
     88     }
     89 
     90     pub fn is_loopback_http(&self) -> bool {
     91         self.url.scheme() == "http" && self.url.host().is_some_and(host_is_loopback)
     92     }
     93 
     94     pub fn approve(self) -> Result<ApprovedBlobUrl, Error> {
     95         if !self.is_https() && !self.is_loopback_http() {
     96             return Err(Error::InsecureBlobUrl);
     97         }
     98         Ok(ApprovedBlobUrl(self))
     99     }
    100 }
    101 
    102 impl fmt::Display for BlobUrl {
    103     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    104         f.write_str(self.as_str())
    105     }
    106 }
    107 
    108 impl FromStr for BlobUrl {
    109     type Err = Error;
    110 
    111     fn from_str(value: &str) -> Result<Self, Self::Err> {
    112         Self::parse(value)
    113     }
    114 }
    115 
    116 #[cfg(feature = "serde")]
    117 impl serde::Serialize for BlobUrl {
    118     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    119     where
    120         S: serde::Serializer,
    121     {
    122         serializer.serialize_str(self.as_str())
    123     }
    124 }
    125 
    126 #[cfg(feature = "serde")]
    127 impl<'de> serde::Deserialize<'de> for BlobUrl {
    128     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    129     where
    130         D: serde::Deserializer<'de>,
    131     {
    132         let value = String::deserialize(deserializer)?;
    133         Self::parse(&value).map_err(serde::de::Error::custom)
    134     }
    135 }
    136 
    137 #[derive(Clone, Debug, PartialEq, Eq, Hash)]
    138 pub struct ApprovedBlobUrl(BlobUrl);
    139 
    140 impl ApprovedBlobUrl {
    141     pub fn as_blob_url(&self) -> &BlobUrl {
    142         &self.0
    143     }
    144 
    145     pub fn as_str(&self) -> &str {
    146         self.0.as_str()
    147     }
    148 
    149     pub fn into_blob_url(self) -> BlobUrl {
    150         self.0
    151     }
    152 }
    153 
    154 impl fmt::Display for ApprovedBlobUrl {
    155     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    156         self.0.fmt(f)
    157     }
    158 }
    159 
    160 fn host_is_loopback(host: Host<&str>) -> bool {
    161     match host {
    162         Host::Domain(domain) => {
    163             domain == "localhost"
    164                 || domain.strip_suffix(".localhost").is_some_and(|prefix| {
    165                     !prefix.is_empty() && prefix.split('.').all(|label| !label.is_empty())
    166                 })
    167         }
    168         Host::Ipv4(address) => address.octets()[0] == 127,
    169         Host::Ipv6(address) => address.segments() == [0, 0, 0, 0, 0, 0, 0, 1],
    170     }
    171 }
    172 
    173 fn validate_authority(value: &str, url: &Url) -> Result<(), Error> {
    174     let raw_host = raw_authority_host(value);
    175     if let Some(port) = raw_authority_port(value) {
    176         match port.parse::<u16>() {
    177             Ok(1..) => {}
    178             Ok(0) | Err(_) => return Err(Error::InvalidBlobUrl),
    179         }
    180     }
    181     match url.host() {
    182         Some(Host::Domain(_)) if !raw_dns_host_is_valid(raw_host) => {
    183             return Err(Error::InvalidBlobUrl);
    184         }
    185         Some(Host::Ipv4(address)) if raw_host != address.to_string() => {
    186             return Err(Error::InvalidBlobUrl);
    187         }
    188         Some(_) => {}
    189         None => return Err(Error::InvalidBlobUrl),
    190     }
    191     Ok(())
    192 }
    193 
    194 fn raw_url_text_is_valid(value: &str) -> bool {
    195     !value.chars().any(|character| {
    196         character.is_whitespace()
    197             || matches!(
    198                 get_general_category(character),
    199                 GeneralCategory::Control | GeneralCategory::Format
    200             )
    201     })
    202 }
    203 
    204 fn raw_dns_host_is_valid(host: &str) -> bool {
    205     !host.is_empty()
    206         && host.is_ascii()
    207         && host.len() <= 253
    208         && host.split('.').all(raw_dns_label_is_valid)
    209 }
    210 
    211 fn raw_dns_label_is_valid(label: &str) -> bool {
    212     let bytes = label.as_bytes();
    213     !bytes.is_empty()
    214         && bytes.len() <= 63
    215         && bytes.first().is_some_and(u8::is_ascii_alphanumeric)
    216         && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
    217         && bytes
    218             .iter()
    219             .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-')
    220 }
    221 
    222 fn raw_authority(value: &str) -> &str {
    223     let (_, remainder) = value
    224         .split_once("://")
    225         .expect("blob URL parser requires an explicit scheme delimiter");
    226     let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
    227     &remainder[..authority_end]
    228 }
    229 
    230 fn raw_authority_host(value: &str) -> &str {
    231     let host_and_port = raw_authority(value);
    232     if let Some(bracketed) = host_and_port.strip_prefix('[') {
    233         return bracketed
    234             .split_once(']')
    235             .expect("validated bracketed URL host must close")
    236             .0;
    237     }
    238     host_and_port
    239         .rsplit_once(':')
    240         .map_or(host_and_port, |(host, _)| host)
    241 }
    242 
    243 fn raw_authority_port(value: &str) -> Option<&str> {
    244     let host_and_port = raw_authority(value);
    245     if let Some(bracketed) = host_and_port.strip_prefix('[') {
    246         let (_, suffix) = bracketed
    247             .split_once(']')
    248             .expect("validated bracketed URL host must close");
    249         return suffix.strip_prefix(':');
    250     }
    251     host_and_port.rsplit_once(':').map(|(_, port)| port)
    252 }
    253 
    254 #[cfg(test)]
    255 mod tests {
    256     use super::*;
    257     use alloc::{
    258         format,
    259         string::{String, ToString},
    260     };
    261 
    262     const HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
    263 
    264     fn url(origin: &str) -> String {
    265         format!("{origin}/{HASH}.txt")
    266     }
    267 
    268     #[test]
    269     fn https_reference_is_structural_and_approved() {
    270         let parsed = BlobUrl::parse(&url("https://cdn.example.com")).unwrap();
    271         assert!(parsed.is_https());
    272         assert!(!parsed.is_loopback_http());
    273         assert_eq!(parsed.hash_path().hash().to_string(), HASH);
    274         assert_eq!(parsed.hash_path().extension().unwrap().as_str(), "txt");
    275         assert_eq!(parsed.scheme(), "https");
    276         assert_eq!(parsed.host(), "cdn.example.com");
    277         assert_eq!(parsed.port(), None);
    278         assert_eq!(parsed.clone().approve().unwrap().as_str(), parsed.as_str());
    279         assert_eq!(parsed.to_string(), url("https://cdn.example.com"));
    280     }
    281 
    282     #[test]
    283     fn loopback_http_reference_set_is_approved() {
    284         for origin in [
    285             "http://localhost:3000",
    286             "http://media.localhost",
    287             "http://127.0.0.1",
    288             "http://127.255.10.9:8080",
    289             "http://[::1]:3000",
    290             "http://[0:0:0:0:0:0:0:1]",
    291         ] {
    292             let parsed = BlobUrl::parse(&url(origin)).unwrap();
    293             assert!(parsed.is_loopback_http(), "{origin}");
    294             let canonical = parsed.as_str().to_string();
    295             let approved = parsed.approve().unwrap();
    296             assert_eq!(approved.as_blob_url().as_str(), canonical);
    297             assert_eq!(approved.clone().into_blob_url().as_str(), canonical);
    298             assert_eq!(approved.to_string(), canonical);
    299         }
    300     }
    301 
    302     #[test]
    303     fn public_and_private_http_are_structural_but_not_approved() {
    304         for origin in [
    305             "http://cdn.example.com",
    306             "http://localhost.example.com",
    307             "http://192.168.1.2",
    308             "http://10.0.0.2",
    309             "http://[::]",
    310         ] {
    311             let parsed = BlobUrl::parse(&url(origin)).unwrap();
    312             assert!(!parsed.is_https());
    313             assert!(!parsed.is_loopback_http());
    314             assert_eq!(parsed.approve(), Err(Error::InsecureBlobUrl), "{origin}");
    315         }
    316     }
    317 
    318     #[test]
    319     fn blob_url_rejects_scheme_host_credentials_query_and_fragment() {
    320         let cases = [
    321             (
    322                 url("ftp://cdn.example.com"),
    323                 Error::UnsupportedBlobUrlScheme,
    324             ),
    325             (
    326                 format!("https://user@cdn.example.com/{HASH}.txt"),
    327                 Error::BlobUrlCredentialsForbidden,
    328             ),
    329             (
    330                 format!("https://:password@cdn.example.com/{HASH}.txt"),
    331                 Error::BlobUrlCredentialsForbidden,
    332             ),
    333             (
    334                 format!("https://@cdn.example.com/{HASH}.txt"),
    335                 Error::BlobUrlCredentialsForbidden,
    336             ),
    337             (
    338                 format!("https://:@cdn.example.com/{HASH}.txt"),
    339                 Error::BlobUrlCredentialsForbidden,
    340             ),
    341             (
    342                 format!("https://cdn.example.com/{HASH}.txt?a=1"),
    343                 Error::BlobUrlQueryForbidden,
    344             ),
    345             (
    346                 format!("https://cdn.example.com/{HASH}.txt#x"),
    347                 Error::BlobUrlFragmentForbidden,
    348             ),
    349         ];
    350         for (value, expected) in cases {
    351             assert_eq!(BlobUrl::parse(&value), Err(expected), "{value}");
    352         }
    353     }
    354 
    355     #[test]
    356     fn blob_url_rejects_non_root_encoded_and_traversal_paths() {
    357         for value in [
    358             format!("https://cdn.example.com/x/{HASH}.txt"),
    359             format!("https://cdn.example.com/{HASH}.txt/x"),
    360             format!("https://cdn.example.com/{HASH}%2etxt"),
    361             format!("https://cdn.example.com/x/./../{HASH}.txt"),
    362             format!("https://cdn.example.com/{HASH}/../{HASH}.txt"),
    363             format!("https://cdn.example.com/{HASH}\\x"),
    364         ] {
    365             assert!(BlobUrl::parse(&value).is_err(), "{value}");
    366         }
    367     }
    368 
    369     #[cfg(feature = "serde")]
    370     #[test]
    371     fn blob_url_serde_revalidates_structure() {
    372         let parsed = BlobUrl::parse(&url("https://cdn.example.com")).unwrap();
    373         let json = serde_json::to_string(&parsed).unwrap();
    374         assert_eq!(serde_json::from_str::<BlobUrl>(&json).unwrap(), parsed);
    375         assert!(serde_json::from_str::<BlobUrl>("false").is_err());
    376         assert!(
    377             serde_json::from_str::<BlobUrl>("\"https://cdn.example.com/not-a-hash.png\"").is_err()
    378         );
    379     }
    380 
    381     #[test]
    382     fn malformed_url_is_rejected() {
    383         assert_eq!(BlobUrl::from_str("not a url"), Err(Error::InvalidBlobUrl));
    384         assert!(BlobUrl::parse(&format!("https:///{HASH}.txt")).is_err());
    385         assert!(BlobUrl::parse(&url("https://cdn.example.com:0")).is_err());
    386         assert!(BlobUrl::parse(&url("https://cdn.example.com:00")).is_err());
    387         assert!(BlobUrl::parse(&url("https://cdn.example.com:")).is_err());
    388         for value in [
    389             format!(" https://cdn.example.com/{HASH}.txt"),
    390             format!("https://cdn.example.com/{HASH}.txt\n"),
    391             format!("https://cdn.example.com/{HASH}.txt\t"),
    392             format!("https://cdn.example.com/{HASH}.txt\u{7f}"),
    393             format!("https://media\u{200b}.example/{HASH}.txt"),
    394             format!("https://media\u{2060}.example/{HASH}.txt"),
    395         ] {
    396             assert_eq!(
    397                 BlobUrl::parse(&value),
    398                 Err(Error::InvalidBlobUrl),
    399                 "{value:?}"
    400             );
    401         }
    402         for origin in [
    403             "http://0177.0.0.1",
    404             "http://0x7f.0.0.1",
    405             "http://127.1",
    406             "https://2130706433",
    407         ] {
    408             assert_eq!(
    409                 BlobUrl::parse(&url(origin)),
    410                 Err(Error::InvalidBlobUrl),
    411                 "{origin}"
    412             );
    413         }
    414     }
    415 
    416     #[test]
    417     fn raw_dns_authority_is_validated_from_preserved_input() {
    418         for origin in [
    419             "https://média.example",
    420             "https://foo_bar.example",
    421             "https://-foo.example",
    422             "https://foo-.example",
    423             "https://foo..example",
    424             "https://.example",
    425             "https://example.",
    426         ] {
    427             assert_eq!(
    428                 BlobUrl::parse(&url(origin)),
    429                 Err(Error::InvalidBlobUrl),
    430                 "{origin}"
    431             );
    432         }
    433 
    434         let label_too_long = "a".repeat(64);
    435         assert_eq!(
    436             BlobUrl::parse(&url(&format!("https://{label_too_long}.example"))),
    437             Err(Error::InvalidBlobUrl)
    438         );
    439         let host_too_long = format!(
    440             "{}.{}.{}.{}",
    441             "a".repeat(63),
    442             "b".repeat(63),
    443             "c".repeat(63),
    444             "d".repeat(62)
    445         );
    446         assert!(host_too_long.len() > 253);
    447         assert_eq!(
    448             BlobUrl::parse(&url(&format!("https://{host_too_long}"))),
    449             Err(Error::InvalidBlobUrl)
    450         );
    451 
    452         let maximum_host = format!(
    453             "{}.{}.{}.{}",
    454             "a".repeat(63),
    455             "b".repeat(63),
    456             "c".repeat(63),
    457             "d".repeat(61)
    458         );
    459         assert_eq!(maximum_host.len(), 253);
    460         assert!(BlobUrl::parse(&url(&format!("https://{maximum_host}"))).is_ok());
    461         assert!(BlobUrl::parse(&url("https://xn--mdia-9oa.example")).is_ok());
    462     }
    463 }