lib

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

hash.rs (11006B)


      1 //! Canonical SHA-256 values, safe file extensions, and root hash paths.
      2 //!
      3 //! [`Sha256`] hashes or parses exact lowercase hexadecimal values. [`HashPath`]
      4 //! models the single root path segment used by Blossom and rejects traversal,
      5 //! escaping, queries, and fragments. These values identify bytes; they do not
      6 //! establish origin, authenticity, or content safety.
      7 
      8 use alloc::string::{String, ToString};
      9 use core::{fmt, str::FromStr};
     10 use sha2::{Digest, Sha256 as Sha256Hasher};
     11 
     12 use crate::error::Error;
     13 
     14 const SHA256_BYTES: usize = 32;
     15 const SHA256_HEX_LENGTH: usize = SHA256_BYTES * 2;
     16 const LOWER_HEX: &[u8; 16] = b"0123456789abcdef";
     17 
     18 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
     19 pub struct Sha256([u8; SHA256_BYTES]);
     20 
     21 impl Sha256 {
     22     pub const fn from_bytes(bytes: [u8; SHA256_BYTES]) -> Self {
     23         Self(bytes)
     24     }
     25 
     26     pub fn digest(bytes: &[u8]) -> Self {
     27         let digest = Sha256Hasher::digest(bytes);
     28         let mut value = [0_u8; SHA256_BYTES];
     29         value.copy_from_slice(&digest);
     30         Self(value)
     31     }
     32 
     33     pub fn from_hex(value: &str) -> Result<Self, Error> {
     34         if value.len() != SHA256_HEX_LENGTH {
     35             return Err(Error::InvalidSha256);
     36         }
     37 
     38         let bytes = value.as_bytes();
     39         let mut decoded = [0_u8; SHA256_BYTES];
     40         for (index, output) in decoded.iter_mut().enumerate() {
     41             let high = decode_nibble(bytes[index * 2])?;
     42             let low = decode_nibble(bytes[index * 2 + 1])?;
     43             *output = (high << 4) | low;
     44         }
     45         Ok(Self(decoded))
     46     }
     47 
     48     pub const fn as_bytes(&self) -> &[u8; SHA256_BYTES] {
     49         &self.0
     50     }
     51 
     52     pub fn to_hex(self) -> String {
     53         let mut output = String::with_capacity(SHA256_HEX_LENGTH);
     54         for byte in self.0 {
     55             output.push(char::from(LOWER_HEX[usize::from(byte >> 4)]));
     56             output.push(char::from(LOWER_HEX[usize::from(byte & 0x0f)]));
     57         }
     58         output
     59     }
     60 }
     61 
     62 impl fmt::Display for Sha256 {
     63     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
     64         f.write_str(&self.to_hex())
     65     }
     66 }
     67 
     68 impl FromStr for Sha256 {
     69     type Err = Error;
     70 
     71     fn from_str(value: &str) -> Result<Self, Self::Err> {
     72         Self::from_hex(value)
     73     }
     74 }
     75 
     76 #[cfg(feature = "serde")]
     77 impl serde::Serialize for Sha256 {
     78     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
     79     where
     80         S: serde::Serializer,
     81     {
     82         serializer.serialize_str(&self.to_hex())
     83     }
     84 }
     85 
     86 #[cfg(feature = "serde")]
     87 impl<'de> serde::Deserialize<'de> for Sha256 {
     88     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     89     where
     90         D: serde::Deserializer<'de>,
     91     {
     92         let value = String::deserialize(deserializer)?;
     93         Self::from_hex(&value).map_err(serde::de::Error::custom)
     94     }
     95 }
     96 
     97 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
     98 pub struct FileExtension(String);
     99 
    100 impl FileExtension {
    101     pub fn parse(value: &str) -> Result<Self, Error> {
    102         if value.is_empty()
    103             || value.split('.').any(str::is_empty)
    104             || !value
    105                 .bytes()
    106                 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
    107         {
    108             return Err(Error::InvalidFileExtension);
    109         }
    110         Ok(Self(value.to_string()))
    111     }
    112 
    113     pub fn as_str(&self) -> &str {
    114         &self.0
    115     }
    116 }
    117 
    118 impl fmt::Display for FileExtension {
    119     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    120         f.write_str(self.as_str())
    121     }
    122 }
    123 
    124 impl FromStr for FileExtension {
    125     type Err = Error;
    126 
    127     fn from_str(value: &str) -> Result<Self, Self::Err> {
    128         Self::parse(value)
    129     }
    130 }
    131 
    132 #[cfg(feature = "serde")]
    133 impl serde::Serialize for FileExtension {
    134     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    135     where
    136         S: serde::Serializer,
    137     {
    138         serializer.serialize_str(self.as_str())
    139     }
    140 }
    141 
    142 #[cfg(feature = "serde")]
    143 impl<'de> serde::Deserialize<'de> for FileExtension {
    144     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    145     where
    146         D: serde::Deserializer<'de>,
    147     {
    148         let value = String::deserialize(deserializer)?;
    149         Self::parse(&value).map_err(serde::de::Error::custom)
    150     }
    151 }
    152 
    153 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    154 pub struct HashPath {
    155     hash: Sha256,
    156     extension: Option<FileExtension>,
    157 }
    158 
    159 impl HashPath {
    160     pub fn new(hash: Sha256, extension: Option<FileExtension>) -> Self {
    161         Self { hash, extension }
    162     }
    163 
    164     pub fn parse(value: &str) -> Result<Self, Error> {
    165         let segment = value.strip_prefix('/').ok_or(Error::InvalidHashPath)?;
    166         if segment.contains('/')
    167             || segment.contains('\\')
    168             || segment.contains('%')
    169             || segment.contains('?')
    170             || segment.contains('#')
    171         {
    172             return Err(Error::InvalidHashPath);
    173         }
    174 
    175         let hash = segment
    176             .get(..SHA256_HEX_LENGTH)
    177             .ok_or(Error::InvalidHashPath)
    178             .and_then(Sha256::from_hex)?;
    179         let suffix = &segment[SHA256_HEX_LENGTH..];
    180         let extension = if suffix.is_empty() {
    181             None
    182         } else {
    183             let value = suffix.strip_prefix('.').ok_or(Error::InvalidHashPath)?;
    184             Some(FileExtension::parse(value)?)
    185         };
    186         Ok(Self::new(hash, extension))
    187     }
    188 
    189     pub const fn hash(&self) -> Sha256 {
    190         self.hash
    191     }
    192 
    193     pub fn extension(&self) -> Option<&FileExtension> {
    194         self.extension.as_ref()
    195     }
    196 
    197     pub fn to_path(&self) -> String {
    198         let mut value = String::with_capacity(
    199             1 + SHA256_HEX_LENGTH
    200                 + self
    201                     .extension
    202                     .as_ref()
    203                     .map_or(0, |extension| 1 + extension.as_str().len()),
    204         );
    205         value.push('/');
    206         value.push_str(&self.hash.to_hex());
    207         if let Some(extension) = &self.extension {
    208             value.push('.');
    209             value.push_str(extension.as_str());
    210         }
    211         value
    212     }
    213 }
    214 
    215 impl fmt::Display for HashPath {
    216     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    217         f.write_str(&self.to_path())
    218     }
    219 }
    220 
    221 impl FromStr for HashPath {
    222     type Err = Error;
    223 
    224     fn from_str(value: &str) -> Result<Self, Self::Err> {
    225         Self::parse(value)
    226     }
    227 }
    228 
    229 #[cfg(feature = "serde")]
    230 impl serde::Serialize for HashPath {
    231     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    232     where
    233         S: serde::Serializer,
    234     {
    235         serializer.serialize_str(&self.to_path())
    236     }
    237 }
    238 
    239 #[cfg(feature = "serde")]
    240 impl<'de> serde::Deserialize<'de> for HashPath {
    241     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    242     where
    243         D: serde::Deserializer<'de>,
    244     {
    245         let value = String::deserialize(deserializer)?;
    246         Self::parse(&value).map_err(serde::de::Error::custom)
    247     }
    248 }
    249 
    250 const fn decode_nibble(byte: u8) -> Result<u8, Error> {
    251     match byte {
    252         b'0'..=b'9' => Ok(byte - b'0'),
    253         b'a'..=b'f' => Ok(byte - b'a' + 10),
    254         _ => Err(Error::InvalidSha256),
    255     }
    256 }
    257 
    258 #[cfg(test)]
    259 mod tests {
    260     use super::*;
    261     use alloc::format;
    262 
    263     const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
    264 
    265     #[test]
    266     fn hash_round_trips_known_digest() {
    267         let hash = Sha256::digest(b"");
    268         assert_eq!(hash.to_string(), EMPTY_SHA256);
    269         assert_eq!(hash.as_bytes().len(), SHA256_BYTES);
    270         assert_eq!(Sha256::from_str(EMPTY_SHA256), Ok(hash));
    271     }
    272 
    273     #[cfg(feature = "serde")]
    274     #[test]
    275     fn hash_serde_round_trips() {
    276         let hash = Sha256::from_str(EMPTY_SHA256).unwrap();
    277         let json = serde_json::to_string(&hash).unwrap();
    278         assert_eq!(serde_json::from_str::<Sha256>(&json).unwrap(), hash);
    279     }
    280 
    281     #[test]
    282     fn hash_preserves_verified_digest_bytes() {
    283         let bytes = [0xabu8; SHA256_BYTES];
    284         let hash = Sha256::from_bytes(bytes);
    285 
    286         assert_eq!(hash.as_bytes(), &bytes);
    287         assert_eq!(hash.to_hex(), "ab".repeat(SHA256_BYTES));
    288     }
    289 
    290     #[test]
    291     fn hash_rejects_wrong_length_case_and_alphabet() {
    292         for value in [
    293             "",
    294             "0",
    295             &"a".repeat(63),
    296             &"a".repeat(65),
    297             &"A".repeat(64),
    298             &"z".repeat(64),
    299         ] {
    300             assert_eq!(Sha256::from_hex(value), Err(Error::InvalidSha256));
    301         }
    302     }
    303 
    304     #[test]
    305     fn extension_accepts_simple_and_compound_values() {
    306         for value in ["png", "tar.gz", "x-custom_2", "PNG"] {
    307             let extension = value.parse::<FileExtension>().unwrap();
    308             assert_eq!(extension.as_str(), value);
    309             assert_eq!(extension.to_string(), value);
    310         }
    311     }
    312 
    313     #[cfg(feature = "serde")]
    314     #[test]
    315     fn extension_serde_round_trips_and_revalidates() {
    316         let extension = FileExtension::parse("tar.gz").unwrap();
    317         let json = serde_json::to_string(&extension).unwrap();
    318         assert_eq!(
    319             serde_json::from_str::<FileExtension>(&json).unwrap(),
    320             extension
    321         );
    322         assert!(serde_json::from_str::<FileExtension>("false").is_err());
    323     }
    324 
    325     #[test]
    326     fn extension_rejects_invalid_values() {
    327         for value in ["", ".png", "png.", "tar..gz", "a/b", "a b", "café"] {
    328             assert_eq!(
    329                 FileExtension::parse(value),
    330                 Err(Error::InvalidFileExtension)
    331             );
    332         }
    333     }
    334 
    335     #[test]
    336     fn hash_path_round_trips_with_and_without_extension() {
    337         let bare = format!("/{EMPTY_SHA256}");
    338         let parsed = HashPath::parse(&bare).unwrap();
    339         assert_eq!(parsed.hash().to_string(), EMPTY_SHA256);
    340         assert_eq!(parsed.extension(), None);
    341         assert_eq!(parsed.to_path(), bare);
    342 
    343         let extended = format!("/{EMPTY_SHA256}.webp");
    344         let parsed = HashPath::from_str(&extended).unwrap();
    345         assert_eq!(parsed.extension().unwrap().as_str(), "webp");
    346         assert_eq!(parsed.to_string(), extended);
    347     }
    348 
    349     #[cfg(feature = "serde")]
    350     #[test]
    351     fn hash_path_serde_round_trips_and_revalidates() {
    352         let path = format!("/{EMPTY_SHA256}.webp");
    353         let parsed = HashPath::parse(&path).unwrap();
    354         let json = serde_json::to_string(&parsed).unwrap();
    355         assert_eq!(serde_json::from_str::<HashPath>(&json).unwrap(), parsed);
    356         assert!(serde_json::from_str::<HashPath>("null").is_err());
    357     }
    358 
    359     #[test]
    360     fn hash_path_rejects_non_root_and_ambiguous_paths() {
    361         for path in [
    362             EMPTY_SHA256,
    363             "/",
    364             "/abc",
    365             &format!("/{EMPTY_SHA256}/x"),
    366             &format!("/{EMPTY_SHA256}%2epng"),
    367             &format!("/{EMPTY_SHA256}.png?download=1"),
    368             &format!("/{EMPTY_SHA256}.png#media"),
    369             &format!("/{EMPTY_SHA256}\\x"),
    370             &format!("/{EMPTY_SHA256}png"),
    371             &format!("/{EMPTY_SHA256}."),
    372         ] {
    373             assert!(HashPath::parse(path).is_err(), "{path}");
    374         }
    375     }
    376 }