Another project
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}