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 }