1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
20#[serde(transparent)]
21pub struct ContentHash(String);
22
23impl<'de> Deserialize<'de> for ContentHash {
24 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
25 let text = String::deserialize(deserializer)?;
26 Self::parse(&text).ok_or_else(|| serde::de::Error::custom("expected blake3:<64 hex digits>"))
27 }
28}
29
30impl ContentHash {
31 pub fn of_text(text: &str) -> Self {
33 let normalised = normalise_newlines(text);
34 Self::of_bytes(normalised.as_bytes())
35 }
36
37 pub fn of_bytes(bytes: &[u8]) -> Self {
39 Self(format!("blake3:{}", blake3::hash(bytes).to_hex()))
40 }
41
42 pub fn parse(s: &str) -> Option<Self> {
46 let hex = s.strip_prefix("blake3:")?;
47 (hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit())).then(|| Self(s.to_ascii_lowercase()))
48 }
49
50 pub fn as_str(&self) -> &str {
52 &self.0
53 }
54
55 pub fn short(&self, n: usize) -> &str {
57 let hex = &self.0["blake3:".len()..];
58 &hex[..n.min(hex.len())]
59 }
60}
61
62impl fmt::Display for ContentHash {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 f.write_str(&self.0)
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
92pub struct Fingerprint([u8; 16]);
93
94impl Fingerprint {
95 pub const PREFIX: &'static str = "blake3-16:";
97
98 pub const fn from_bytes(bytes: [u8; 16]) -> Self {
100 Self(bytes)
101 }
102
103 pub fn from_blake3(hash: &blake3::Hash) -> Self {
105 let mut b = [0u8; 16];
106 b.copy_from_slice(&hash.as_bytes()[..16]);
107 Self(b)
108 }
109
110 pub fn as_bytes(&self) -> &[u8; 16] {
112 &self.0
113 }
114
115 pub fn is_unset(&self) -> bool {
117 self.0 == [0; 16]
118 }
119
120 pub fn parse(s: &str) -> Option<Self> {
122 let hex = s.strip_prefix(Self::PREFIX)?;
123 if hex.len() != 32 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
124 return None;
125 }
126 let mut b = [0u8; 16];
127 for (i, out) in b.iter_mut().enumerate() {
128 *out = u8::from_str_radix(hex.get(2 * i..2 * i + 2)?, 16).ok()?;
129 }
130 Some(Self(b))
131 }
132
133 pub fn merge(self, other: Self) -> Self {
146 let (lo, hi) = if self <= other { (self, other) } else { (other, self) };
147 let mut h = blake3::Hasher::new_derive_key("sealmap sm1 2026-10 merged twin fingerprints");
148 h.update(&lo.0);
149 h.update(&hi.0);
150 Self::from_blake3(&h.finalize())
151 }
152}
153
154impl fmt::Display for Fingerprint {
155 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156 f.write_str(Self::PREFIX)?;
157 for b in &self.0 {
158 write!(f, "{b:02x}")?;
159 }
160 Ok(())
161 }
162}
163
164impl Serialize for Fingerprint {
165 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
166 serializer.collect_str(self)
167 }
168}
169
170impl<'de> Deserialize<'de> for Fingerprint {
171 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
172 let s = String::deserialize(deserializer)?;
173 Self::parse(&s).ok_or_else(|| serde::de::Error::custom(format!("invalid fingerprint `{s}`")))
174 }
175}
176
177pub(crate) fn normalise_newlines(text: &str) -> std::borrow::Cow<'_, str> {
179 if !text.contains('\r') {
180 return std::borrow::Cow::Borrowed(text);
181 }
182 std::borrow::Cow::Owned(text.replace("\r\n", "\n").replace('\r', "\n"))
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn fingerprint_rejects_signed_byte_pairs_and_accepts_hex() {
191 for pair in ["+0", "+a", "-0", " g"] {
192 let text = format!("blake3-16:{}", pair.repeat(16));
193 assert!(Fingerprint::parse(&text).is_none(), "{text}");
194 }
195 for pair in ["00", "af", "AF"] {
196 let text = format!("blake3-16:{}", pair.repeat(16));
197 assert!(Fingerprint::parse(&text).is_some(), "{text}");
198 }
199 }
200
201 #[test]
202 fn parse_round_trips() {
203 let h = ContentHash::of_text("x");
204 assert_eq!(ContentHash::parse(h.as_str()), Some(h.clone()));
205 assert_eq!(ContentHash::parse("md5:abc"), None);
206 assert_eq!(h.short(8).len(), 8);
207 }
208
209 #[test]
210 fn deserialisation_preserves_the_hash_invariant() {
211 for bad in ["", "x", "md5:abc", "blake3:abc", "blake3:🦀"] {
212 let json = serde_json::to_string(bad).unwrap();
213 assert!(serde_json::from_str::<ContentHash>(&json).is_err(), "{bad:?}");
214 }
215 let hash = ContentHash::of_text("receipt");
216 let parsed: ContentHash = serde_json::from_str(&serde_json::to_string(&hash).unwrap()).unwrap();
217 assert_eq!(parsed.short(999), &hash.as_str()[7..]);
218 }
219}