Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 7.0 kB View raw
1use std::path::PathBuf; 2 3use serde::de::{Error as _, Unexpected}; 4use serde::{Deserialize, Deserializer, Serialize, Serializer}; 5 6#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 7pub struct BlobHash(blake3::Hash); 8 9impl BlobHash { 10 #[must_use] 11 pub fn of(bytes: &[u8]) -> Self { 12 Self(blake3::hash(bytes)) 13 } 14 15 #[must_use] 16 pub fn of_pair(first: &[u8], second: &[u8]) -> Self { 17 let Ok(first_len) = u64::try_from(first.len()) else { 18 unreachable!("slice length exceeds u64 only above 64-bit platforms"); 19 }; 20 let mut hasher = blake3::Hasher::new(); 21 hasher.update(&first_len.to_le_bytes()); 22 hasher.update(first); 23 hasher.update(second); 24 Self(hasher.finalize()) 25 } 26 27 #[must_use] 28 pub const fn from_bytes(bytes: [u8; 32]) -> Self { 29 Self(blake3::Hash::from_bytes(bytes)) 30 } 31 32 #[must_use] 33 pub fn bytes(self) -> [u8; 32] { 34 *self.0.as_bytes() 35 } 36 37 #[must_use] 38 pub fn full_hex(self) -> String { 39 self.0.to_hex().to_string() 40 } 41 42 #[must_use] 43 pub fn truncated_128_hex(self) -> String { 44 self.0.to_hex()[..32].to_string() 45 } 46 47 #[must_use] 48 pub fn relative_path(self, kind: BlobKind) -> PathBuf { 49 let hex = self.truncated_128_hex(); 50 let (aa, rest) = hex.split_at(2); 51 PathBuf::from(aa).join(format!("{rest}.{ext}", ext = kind.as_str())) 52 } 53} 54 55impl core::fmt::Display for BlobHash { 56 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 57 write!(f, "{}", self.0.to_hex()) 58 } 59} 60 61impl Serialize for BlobHash { 62 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { 63 serializer.serialize_str(&self.full_hex()) 64 } 65} 66 67impl<'de> Deserialize<'de> for BlobHash { 68 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { 69 let hex = String::deserialize(deserializer)?; 70 blake3::Hash::from_hex(&hex).map(Self).map_err(|_| { 71 D::Error::invalid_value(Unexpected::Str(&hex), &"a 64-char BLAKE3 hex digest") 72 }) 73 } 74} 75 76#[derive(Debug, thiserror::Error)] 77#[error("blob kind extension must be non-empty lowercase ascii alphanumeric")] 78pub struct BlobKindError; 79 80#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 81pub struct BlobKind(&'static str); 82 83impl BlobKind { 84 pub const STEP: Self = Self("step"); 85 pub const TESS: Self = Self("tess"); 86 pub const THUMB: Self = Self("thumb"); 87 pub const LABELS: Self = Self("labels"); 88 pub const BREP: Self = Self("brep"); 89 90 pub fn new(ext: &'static str) -> Result<Self, BlobKindError> { 91 let valid = !ext.is_empty() 92 && ext 93 .bytes() 94 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()); 95 valid.then_some(Self(ext)).ok_or(BlobKindError) 96 } 97 98 #[must_use] 99 pub const fn as_str(self) -> &'static str { 100 self.0 101 } 102} 103 104impl core::fmt::Display for BlobKind { 105 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 106 f.write_str(self.0) 107 } 108} 109 110#[cfg(test)] 111mod tests { 112 use super::*; 113 114 #[test] 115 fn of_matches_blake3_reference() { 116 let hash = BlobHash::of(b""); 117 assert_eq!( 118 hash.full_hex(), 119 "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262" 120 ); 121 } 122 123 #[test] 124 fn of_pair_frames_the_boundary_between_parts() { 125 assert_ne!( 126 BlobHash::of_pair(b"AB", b"C"), 127 BlobHash::of_pair(b"A", b"BC"), 128 "a different split of the same concatenation must not collide" 129 ); 130 assert_ne!( 131 BlobHash::of_pair(b"AB", b"C"), 132 BlobHash::of(b"ABC"), 133 "framed pair must not collide with the bare concatenation" 134 ); 135 assert_ne!( 136 BlobHash::of_pair(b"", b"X"), 137 BlobHash::of_pair(b"X", b""), 138 "an empty leading part is distinguishable from an empty trailing part" 139 ); 140 } 141 142 #[test] 143 fn truncated_is_first_32_hex_chars() { 144 let hash = BlobHash::of(b"example"); 145 let full = hash.full_hex(); 146 let short = hash.truncated_128_hex(); 147 assert_eq!(short.len(), 32); 148 assert_eq!(short, full[..32]); 149 } 150 151 #[test] 152 fn relative_path_is_fanout_then_rest_dot_kind() { 153 let hash = BlobHash::of(b"example"); 154 let path = hash.relative_path(BlobKind::STEP); 155 let parts: Vec<String> = path 156 .components() 157 .map(|c| c.as_os_str().to_string_lossy().into_owned()) 158 .collect(); 159 let Some([fanout, file]) = parts 160 .get(..2) 161 .and_then(|s| <&[String; 2]>::try_from(s).ok()) 162 else { 163 panic!("two components, got {parts:?}"); 164 }; 165 assert_eq!(fanout.len(), 2); 166 let Some(stem) = file.strip_suffix(".step") else { 167 panic!("ends with .step: {file}"); 168 }; 169 assert_eq!(stem.len(), 30); 170 let hex = hash.truncated_128_hex(); 171 assert_eq!(fanout, &hex[..2]); 172 assert_eq!(stem, &hex[2..]); 173 } 174 175 #[test] 176 fn from_bytes_roundtrips_through_bytes() { 177 let bytes = [7u8; 32]; 178 let hash = BlobHash::from_bytes(bytes); 179 assert_eq!(hash.bytes(), bytes); 180 } 181 182 #[test] 183 fn blob_kind_presets_match_adr_examples() { 184 assert_eq!(BlobKind::STEP.as_str(), "step"); 185 assert_eq!(BlobKind::TESS.as_str(), "tess"); 186 assert_eq!(BlobKind::THUMB.as_str(), "thumb"); 187 assert_eq!(BlobKind::LABELS.as_str(), "labels"); 188 } 189 190 #[test] 191 fn serde_round_trips_through_hex() { 192 let hash = BlobHash::of(b"nel"); 193 let Ok(text) = crate::io::ron_io::to_string(&hash) else { 194 panic!("serialize blob hash"); 195 }; 196 assert!(text.contains(&hash.full_hex())); 197 let Ok(back) = crate::io::ron_io::from_str::<BlobHash>(&text) else { 198 panic!("deserialize blob hash"); 199 }; 200 assert_eq!(hash, back); 201 } 202 203 #[test] 204 fn deserialize_rejects_non_hex() { 205 assert!(crate::io::ron_io::from_str::<BlobHash>("\"not a digest\"").is_err()); 206 } 207 208 #[test] 209 fn blob_kind_new_accepts_lowercase_alphanumeric() { 210 let Ok(gltf) = BlobKind::new("gltf") else { 211 panic!("gltf is a valid extension"); 212 }; 213 let Ok(v1) = BlobKind::new("v1") else { 214 panic!("v1 is a valid extension"); 215 }; 216 assert_eq!(gltf.as_str(), "gltf"); 217 assert_eq!(v1.as_str(), "v1"); 218 } 219 220 #[test] 221 fn blob_kind_new_rejects_empty() { 222 assert!(BlobKind::new("").is_err()); 223 } 224 225 #[test] 226 fn blob_kind_new_rejects_uppercase() { 227 assert!(BlobKind::new("STEP").is_err()); 228 } 229 230 #[test] 231 fn blob_kind_new_rejects_path_separator() { 232 assert!(BlobKind::new("bad/ext").is_err()); 233 } 234 235 #[test] 236 fn blob_kind_new_rejects_dot() { 237 assert!(BlobKind::new("step.bak").is_err()); 238 } 239}