Skip to main content

sealmap_model/
hash.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5/// A stable, portable content hash: BLAKE3 over text with `\r\n` and lone
6/// `\r` normalised to `\n`.
7///
8/// Displayed and serialised as `blake3:<64 hex chars>`, so the algorithm is
9/// self-describing if it ever changes.
10///
11/// ```
12/// use sealmap_model::ContentHash;
13///
14/// let unix = ContentHash::of_text("fn main() {}\n");
15/// let windows = ContentHash::of_text("fn main() {}\r\n");
16/// assert_eq!(unix, windows);
17/// assert!(unix.to_string().starts_with("blake3:"));
18/// ```
19#[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    /// Hash `text` after normalising line endings.
32    pub fn of_text(text: &str) -> Self {
33        let normalised = normalise_newlines(text);
34        Self::of_bytes(normalised.as_bytes())
35    }
36
37    /// Hash raw bytes with no normalisation.
38    pub fn of_bytes(bytes: &[u8]) -> Self {
39        Self(format!("blake3:{}", blake3::hash(bytes).to_hex()))
40    }
41
42    /// Parse a previously displayed hash (`blake3:<hex>`).
43    ///
44    /// Returns `None` if the prefix or the hex payload is malformed.
45    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    /// The full `blake3:<hex>` string.
51    pub fn as_str(&self) -> &str {
52        &self.0
53    }
54
55    /// The first `n` hex characters of the digest, handy for short stable ids.
56    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/// A per-symbol content fingerprint: the first 16 bytes of a BLAKE3 digest.
69///
70/// Language adapters compute two per symbol (see `sealmap-extract`'s `fingerprint`
71/// module): `sig_hash` over the contract and `body_hash` over the
72/// implementation. The model only stores, compares, prints and parses them.
73///
74/// Displayed and serialised as `blake3-16:<32 hex chars>`. 128 bits keeps an
75/// accidental collision out of reach for any realistic number of symbols
76/// while halving the text a full digest would cost in every model and lock.
77///
78/// [`Fingerprint::default`] is all zeros and means "not fingerprinted": a
79/// symbol built by hand with [`Symbol::new`](crate::Symbol::new) carries it
80/// until a language adapter fills in real values.
81///
82/// ```
83/// use sealmap_model::Fingerprint;
84///
85/// let f = Fingerprint::from_bytes([0xab; 16]);
86/// let text = f.to_string();
87/// assert_eq!(text, format!("blake3-16:{}", "ab".repeat(16)));
88/// assert_eq!(Fingerprint::parse(&text), Some(f));
89/// assert!(Fingerprint::default().is_unset());
90/// ```
91#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
92pub struct Fingerprint([u8; 16]);
93
94impl Fingerprint {
95    /// The text prefix naming the algorithm and length.
96    pub const PREFIX: &'static str = "blake3-16:";
97
98    /// Wrap 16 digest bytes.
99    pub const fn from_bytes(bytes: [u8; 16]) -> Self {
100        Self(bytes)
101    }
102
103    /// Truncate a full BLAKE3 digest.
104    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    /// The digest bytes.
111    pub fn as_bytes(&self) -> &[u8; 16] {
112        &self.0
113    }
114
115    /// `true` for the all-zero "not fingerprinted" value.
116    pub fn is_unset(&self) -> bool {
117        self.0 == [0; 16]
118    }
119
120    /// Parse `blake3-16:<32 hex>` (either case).
121    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    /// Fold two fingerprints of the same symbol into one, independent of
134    /// their order. Used when `#[cfg]`-gated twins share an id and are
135    /// merged by [`Codebase::add_symbol`](crate::Codebase::add_symbol): a
136    /// change to either twin changes the merged value.
137    ///
138    /// ```
139    /// use sealmap_model::Fingerprint;
140    ///
141    /// let (a, b) = (Fingerprint::from_bytes([1; 16]), Fingerprint::from_bytes([2; 16]));
142    /// assert_eq!(a.merge(b), b.merge(a));
143    /// assert_ne!(a.merge(b), a);
144    /// ```
145    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
177/// Convert `\r\n` and lone `\r` to `\n`.
178pub(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}