descriptor.rs (13702B)
1 //! BUD-02 descriptors and explicit reference- and byte-verification states. 2 //! 3 //! [`BlobDescriptor`] establishes structural descriptor invariants. 4 //! [`ApprovedDescriptor`] additionally proves the URL passed the Radroots 5 //! HTTPS-or-loopback policy, and [`ByteVerifiedDescriptor`] proves descriptor 6 //! agreement with a local [`ByteCommitment`]. None of these states attests that 7 //! network upload, retrieval, content scanning, or durable storage occurred. 8 9 use crate::{ 10 Error, MediaType, Sha256, 11 url::{ApprovedBlobUrl, BlobUrl}, 12 }; 13 14 const _: () = assert!(usize::BITS <= u64::BITS); 15 16 #[derive(Clone, Debug, PartialEq, Eq)] 17 pub struct BlobDescriptor { 18 url: BlobUrl, 19 sha256: Sha256, 20 size: u64, 21 media_type: MediaType, 22 uploaded: u64, 23 } 24 25 impl BlobDescriptor { 26 pub fn new( 27 url: BlobUrl, 28 sha256: Sha256, 29 size: u64, 30 media_type: MediaType, 31 uploaded: u64, 32 ) -> Result<Self, Error> { 33 if url.hash_path().extension().is_none() { 34 return Err(Error::DescriptorExtensionRequired); 35 } 36 if url.hash_path().hash() != sha256 { 37 return Err(Error::DescriptorHashMismatch); 38 } 39 Ok(Self { 40 url, 41 sha256, 42 size, 43 media_type, 44 uploaded, 45 }) 46 } 47 48 pub fn url(&self) -> &BlobUrl { 49 &self.url 50 } 51 52 pub const fn sha256(&self) -> Sha256 { 53 self.sha256 54 } 55 56 pub const fn size(&self) -> u64 { 57 self.size 58 } 59 60 pub fn media_type(&self) -> &MediaType { 61 &self.media_type 62 } 63 64 pub const fn uploaded(&self) -> u64 { 65 self.uploaded 66 } 67 68 pub fn approve_reference(self) -> Result<ApprovedDescriptor, Error> { 69 let approved_url = self.url.clone().approve()?; 70 Ok(ApprovedDescriptor { 71 descriptor: self, 72 approved_url, 73 }) 74 } 75 } 76 77 #[cfg(feature = "serde")] 78 #[derive(serde::Serialize)] 79 struct DescriptorRef<'a> { 80 url: &'a BlobUrl, 81 sha256: Sha256, 82 size: u64, 83 #[serde(rename = "type")] 84 media_type: &'a MediaType, 85 uploaded: u64, 86 } 87 88 #[cfg(feature = "serde")] 89 #[derive(serde::Deserialize)] 90 struct DescriptorWire { 91 url: BlobUrl, 92 sha256: Sha256, 93 size: u64, 94 #[serde(rename = "type")] 95 media_type: MediaType, 96 uploaded: u64, 97 } 98 99 #[cfg(feature = "serde")] 100 impl serde::Serialize for BlobDescriptor { 101 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 102 where 103 S: serde::Serializer, 104 { 105 DescriptorRef { 106 url: &self.url, 107 sha256: self.sha256, 108 size: self.size, 109 media_type: &self.media_type, 110 uploaded: self.uploaded, 111 } 112 .serialize(serializer) 113 } 114 } 115 116 #[cfg(feature = "serde")] 117 impl<'de> serde::Deserialize<'de> for BlobDescriptor { 118 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 119 where 120 D: serde::Deserializer<'de>, 121 { 122 let wire = DescriptorWire::deserialize(deserializer)?; 123 Self::new( 124 wire.url, 125 wire.sha256, 126 wire.size, 127 wire.media_type, 128 wire.uploaded, 129 ) 130 .map_err(serde::de::Error::custom) 131 } 132 } 133 134 #[derive(Clone, Debug, PartialEq, Eq)] 135 pub struct ApprovedDescriptor { 136 descriptor: BlobDescriptor, 137 approved_url: ApprovedBlobUrl, 138 } 139 140 #[derive(Clone, Debug, PartialEq, Eq)] 141 pub struct ByteCommitment { 142 sha256: Sha256, 143 size: u64, 144 media_type: MediaType, 145 } 146 147 impl ByteCommitment { 148 pub fn from_bytes(bytes: &[u8], media_type: MediaType) -> Self { 149 Self { 150 sha256: Sha256::digest(bytes), 151 size: bytes.len() as u64, 152 media_type, 153 } 154 } 155 156 pub const fn sha256(&self) -> Sha256 { 157 self.sha256 158 } 159 160 pub const fn size(&self) -> u64 { 161 self.size 162 } 163 164 pub fn media_type(&self) -> &MediaType { 165 &self.media_type 166 } 167 } 168 169 impl ApprovedDescriptor { 170 pub fn descriptor(&self) -> &BlobDescriptor { 171 &self.descriptor 172 } 173 174 pub fn url(&self) -> &ApprovedBlobUrl { 175 &self.approved_url 176 } 177 178 pub fn into_descriptor(self) -> BlobDescriptor { 179 self.descriptor 180 } 181 182 pub fn verify_bytes( 183 self, 184 bytes: &[u8], 185 approved_media_type: &MediaType, 186 ) -> Result<ByteVerifiedDescriptor, Error> { 187 let commitment = ByteCommitment::from_bytes(bytes, approved_media_type.clone()); 188 self.verify_commitment(&commitment) 189 } 190 191 pub fn verify_commitment( 192 self, 193 commitment: &ByteCommitment, 194 ) -> Result<ByteVerifiedDescriptor, Error> { 195 if self.descriptor.size != commitment.size { 196 return Err(Error::BlobSizeMismatch { 197 expected: self.descriptor.size, 198 actual: commitment.size, 199 }); 200 } 201 if self.descriptor.media_type != commitment.media_type { 202 return Err(Error::BlobMediaTypeMismatch); 203 } 204 if self.descriptor.sha256 != commitment.sha256 { 205 return Err(Error::BlobHashMismatch); 206 } 207 Ok(ByteVerifiedDescriptor(self)) 208 } 209 } 210 211 /// An approved descriptor whose hash, size, and media type match supplied bytes. 212 /// 213 /// This state does not attest that a network upload occurred. 214 /// Its private representation prevents callers from forging the typestate: 215 /// 216 /// ```compile_fail 217 /// use radroots_blossom::{ByteVerifiedDescriptor, descriptor::ApprovedDescriptor}; 218 /// 219 /// fn forge(approved: ApprovedDescriptor) -> ByteVerifiedDescriptor { 220 /// ByteVerifiedDescriptor(approved) 221 /// } 222 /// ``` 223 /// 224 /// Construction is only available through descriptor verification: 225 /// 226 /// ```compile_fail 227 /// use radroots_blossom::{ByteVerifiedDescriptor, descriptor::ApprovedDescriptor}; 228 /// 229 /// fn bypass_verification(approved: ApprovedDescriptor) -> ByteVerifiedDescriptor { 230 /// ByteVerifiedDescriptor::new(approved) 231 /// } 232 /// ``` 233 #[derive(Clone, Debug, PartialEq, Eq)] 234 pub struct ByteVerifiedDescriptor(ApprovedDescriptor); 235 236 impl ByteVerifiedDescriptor { 237 pub fn descriptor(&self) -> &BlobDescriptor { 238 self.0.descriptor() 239 } 240 241 pub fn url(&self) -> &ApprovedBlobUrl { 242 self.0.url() 243 } 244 245 pub const fn sha256(&self) -> Sha256 { 246 self.0.descriptor.sha256 247 } 248 249 pub const fn size(&self) -> u64 { 250 self.0.descriptor.size 251 } 252 253 pub fn media_type(&self) -> &MediaType { 254 &self.0.descriptor.media_type 255 } 256 257 pub fn into_descriptor(self) -> BlobDescriptor { 258 self.0.into_descriptor() 259 } 260 } 261 262 #[cfg(test)] 263 mod tests { 264 use super::*; 265 use alloc::{format, string::ToString}; 266 use core::str::FromStr; 267 268 const HELLO_HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; 269 270 fn descriptor(origin: &str, bytes: &[u8], media_type: &str) -> BlobDescriptor { 271 let hash = Sha256::digest(bytes); 272 BlobDescriptor::new( 273 BlobUrl::parse(&format!("{origin}/{hash}.txt")).unwrap(), 274 hash, 275 u64::try_from(bytes.len()).unwrap(), 276 MediaType::parse(media_type).unwrap(), 277 1_725_105_921, 278 ) 279 .unwrap() 280 } 281 282 #[test] 283 fn media_type_canonicalizes_parameters_and_compares_case_insensitively() { 284 let lower = MediaType::parse("image/svg+xml; profile=web; charset=UTF-8").unwrap(); 285 let upper = MediaType::from_str("IMAGE/SVG+XML; CHARSET=UTF-8; PROFILE=web").unwrap(); 286 assert_eq!(lower, upper); 287 assert_eq!(lower.as_str(), "image/svg+xml; charset=UTF-8; profile=web"); 288 assert_eq!(upper.as_str(), lower.as_str()); 289 assert_eq!(lower.to_string(), lower.as_str()); 290 } 291 292 #[cfg(feature = "serde")] 293 #[test] 294 fn media_type_serde_round_trips_and_revalidates() { 295 let media_type = MediaType::parse("image/svg+xml; charset=UTF-8").unwrap(); 296 let json = serde_json::to_string(&media_type).unwrap(); 297 assert_eq!( 298 serde_json::from_str::<MediaType>(&json).unwrap(), 299 media_type 300 ); 301 assert!(serde_json::from_str::<MediaType>("42").is_err()); 302 } 303 304 #[test] 305 fn media_type_rejects_invalid_values_and_types() { 306 for value in [ 307 "", 308 "image", 309 "image/", 310 "*/*", 311 "image/*", 312 "image/png; profile=a; PROFILE=b", 313 " image/png", 314 "image/png ", 315 "image/png\n", 316 ] { 317 assert_eq!(MediaType::parse(value), Err(Error::InvalidMediaType)); 318 } 319 } 320 321 #[test] 322 fn descriptor_requires_extension_and_matching_url_hash() { 323 let hash = Sha256::from_hex(HELLO_HASH).unwrap(); 324 let media_type = MediaType::parse("text/plain").unwrap(); 325 let no_extension = 326 BlobUrl::parse(&format!("https://cdn.example.com/{HELLO_HASH}")).unwrap(); 327 assert_eq!( 328 BlobDescriptor::new(no_extension, hash, 5, media_type.clone(), 1), 329 Err(Error::DescriptorExtensionRequired) 330 ); 331 let wrong_hash = Sha256::digest(b"wrong"); 332 let url = BlobUrl::parse(&format!("https://cdn.example.com/{HELLO_HASH}.txt")).unwrap(); 333 assert_eq!( 334 BlobDescriptor::new(url, wrong_hash, 5, media_type, 1), 335 Err(Error::DescriptorHashMismatch) 336 ); 337 } 338 339 #[cfg(feature = "serde")] 340 #[test] 341 fn descriptor_serde_roundtrip_tolerates_extension_fields() { 342 let raw = format!( 343 r#"{{"url":"https://cdn.example.com/{HELLO_HASH}.txt","sha256":"{HELLO_HASH}","size":5,"type":"text/plain","uploaded":1725105921,"magnet":"magnet:?xt=urn:test"}}"# 344 ); 345 let parsed: BlobDescriptor = serde_json::from_str(&raw).unwrap(); 346 assert_eq!(parsed.url().hash_path().hash().to_string(), HELLO_HASH); 347 assert_eq!(parsed.sha256().to_string(), HELLO_HASH); 348 assert_eq!(parsed.size(), 5); 349 assert_eq!(parsed.media_type().as_str(), "text/plain"); 350 assert_eq!(parsed.uploaded(), 1_725_105_921); 351 let encoded = serde_json::to_value(&parsed).unwrap(); 352 assert_eq!(encoded["type"], "text/plain"); 353 assert!(encoded.get("magnet").is_none()); 354 } 355 356 #[cfg(feature = "serde")] 357 #[test] 358 fn descriptor_deserialize_revalidates_invariants() { 359 let wrong_hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; 360 let raw = format!( 361 r#"{{"url":"https://cdn.example.com/{HELLO_HASH}.txt","sha256":"{wrong_hash}","size":5,"type":"text/plain","uploaded":1}}"# 362 ); 363 assert!(serde_json::from_str::<BlobDescriptor>(&raw).is_err()); 364 } 365 366 #[test] 367 fn byte_verified_descriptor_requires_approved_url_size_type_and_hash() { 368 let media_type = MediaType::parse("text/plain").unwrap(); 369 let commitment = ByteCommitment::from_bytes(b"hello", media_type.clone()); 370 assert_eq!(commitment.sha256().to_string(), HELLO_HASH); 371 assert_eq!(commitment.size(), 5); 372 assert_eq!(commitment.media_type(), &media_type); 373 let approved = descriptor("https://cdn.example.com", b"hello", "text/plain") 374 .approve_reference() 375 .unwrap(); 376 assert_eq!( 377 approved.url().as_str(), 378 approved.descriptor().url().as_str() 379 ); 380 let verified = approved.clone().verify_commitment(&commitment).unwrap(); 381 assert_eq!(verified.url().as_str(), approved.url().as_str()); 382 assert_eq!(verified.descriptor(), approved.descriptor()); 383 assert_eq!(verified.sha256().to_string(), HELLO_HASH); 384 assert_eq!(verified.size(), 5); 385 assert_eq!(verified.media_type(), &media_type); 386 assert_eq!(verified.clone().into_descriptor(), *approved.descriptor()); 387 assert_eq!(approved.clone().into_descriptor(), *approved.descriptor()); 388 389 assert_eq!( 390 descriptor("https://cdn.example.com", b"hello", "text/plain") 391 .approve_reference() 392 .unwrap() 393 .verify_bytes(b"hell", &media_type), 394 Err(Error::BlobSizeMismatch { 395 expected: 5, 396 actual: 4, 397 }) 398 ); 399 let image_type = MediaType::parse("image/png").unwrap(); 400 assert_eq!( 401 descriptor("https://cdn.example.com", b"hello", "text/plain") 402 .approve_reference() 403 .unwrap() 404 .verify_bytes(b"hello", &image_type), 405 Err(Error::BlobMediaTypeMismatch) 406 ); 407 let hash_mismatch = BlobDescriptor::new( 408 BlobUrl::parse(&format!( 409 "https://cdn.example.com/{}.txt", 410 Sha256::digest(b"world") 411 )) 412 .unwrap(), 413 Sha256::digest(b"world"), 414 5, 415 media_type.clone(), 416 1, 417 ) 418 .unwrap() 419 .approve_reference() 420 .unwrap(); 421 assert_eq!( 422 hash_mismatch.verify_bytes(b"hello", &media_type), 423 Err(Error::BlobHashMismatch) 424 ); 425 } 426 427 #[test] 428 fn empty_blob_can_be_verified_with_explicit_default_media_type() { 429 let media_type = MediaType::parse("application/octet-stream").unwrap(); 430 let verified = descriptor("http://localhost:3000", b"", "application/octet-stream") 431 .approve_reference() 432 .unwrap() 433 .verify_bytes(b"", &media_type) 434 .unwrap(); 435 assert_eq!(verified.size(), 0); 436 } 437 438 #[test] 439 fn public_http_descriptor_cannot_advance_to_approved() { 440 assert_eq!( 441 descriptor("http://cdn.example.com", b"hello", "text/plain").approve_reference(), 442 Err(Error::InsecureBlobUrl) 443 ); 444 } 445 }