lib

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

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 }