1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
20#[serde(transparent)]
21pub struct ContentHash(String);
22
23impl ContentHash {
24 pub fn of_text(text: &str) -> Self {
26 let normalised = normalise_newlines(text);
27 Self::of_bytes(normalised.as_bytes())
28 }
29
30 pub fn of_bytes(bytes: &[u8]) -> Self {
32 Self(format!("blake3:{}", blake3::hash(bytes).to_hex()))
33 }
34
35 pub fn parse(s: &str) -> Option<Self> {
39 let hex = s.strip_prefix("blake3:")?;
40 (hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit())).then(|| Self(s.to_ascii_lowercase()))
41 }
42
43 pub fn as_str(&self) -> &str {
45 &self.0
46 }
47
48 pub fn short(&self, n: usize) -> &str {
50 let hex = &self.0["blake3:".len()..];
51 &hex[..n.min(hex.len())]
52 }
53}
54
55impl fmt::Display for ContentHash {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 f.write_str(&self.0)
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
85pub struct Fingerprint([u8; 16]);
86
87impl Fingerprint {
88 pub const PREFIX: &'static str = "blake3-16:";
90
91 pub const fn from_bytes(bytes: [u8; 16]) -> Self {
93 Self(bytes)
94 }
95
96 pub fn from_blake3(hash: &blake3::Hash) -> Self {
98 let mut b = [0u8; 16];
99 b.copy_from_slice(&hash.as_bytes()[..16]);
100 Self(b)
101 }
102
103 pub fn as_bytes(&self) -> &[u8; 16] {
105 &self.0
106 }
107
108 pub fn is_unset(&self) -> bool {
110 self.0 == [0; 16]
111 }
112
113 pub fn parse(s: &str) -> Option<Self> {
115 let hex = s.strip_prefix(Self::PREFIX)?;
116 if hex.len() != 32 {
117 return None;
118 }
119 let mut b = [0u8; 16];
120 for (i, out) in b.iter_mut().enumerate() {
121 *out = u8::from_str_radix(hex.get(2 * i..2 * i + 2)?, 16).ok()?;
122 }
123 Some(Self(b))
124 }
125
126 pub fn merge(self, other: Self) -> Self {
139 let (lo, hi) = if self <= other { (self, other) } else { (other, self) };
140 let mut h = blake3::Hasher::new_derive_key("sealmap sm1 2026-10 merged twin fingerprints");
141 h.update(&lo.0);
142 h.update(&hi.0);
143 Self::from_blake3(&h.finalize())
144 }
145}
146
147impl fmt::Display for Fingerprint {
148 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149 f.write_str(Self::PREFIX)?;
150 for b in &self.0 {
151 write!(f, "{b:02x}")?;
152 }
153 Ok(())
154 }
155}
156
157impl Serialize for Fingerprint {
158 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
159 serializer.collect_str(self)
160 }
161}
162
163impl<'de> Deserialize<'de> for Fingerprint {
164 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
165 let s = String::deserialize(deserializer)?;
166 Self::parse(&s).ok_or_else(|| serde::de::Error::custom(format!("invalid fingerprint `{s}`")))
167 }
168}
169
170pub(crate) fn normalise_newlines(text: &str) -> std::borrow::Cow<'_, str> {
172 if !text.contains('\r') {
173 return std::borrow::Cow::Borrowed(text);
174 }
175 std::borrow::Cow::Owned(text.replace("\r\n", "\n").replace('\r', "\n"))
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn parse_round_trips() {
184 let h = ContentHash::of_text("x");
185 assert_eq!(ContentHash::parse(h.as_str()), Some(h.clone()));
186 assert_eq!(ContentHash::parse("md5:abc"), None);
187 assert_eq!(h.short(8).len(), 8);
188 }
189}