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 }