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, Deserialize)]
20#[serde(transparent)]
21pub struct ContentHash(String);
22
23impl ContentHash {
24    /// Hash `text` after normalising line endings.
25    pub fn of_text(text: &str) -> Self {
26        let normalised = normalise_newlines(text);
27        Self::of_bytes(normalised.as_bytes())
28    }
29
30    /// Hash raw bytes with no normalisation.
31    pub fn of_bytes(bytes: &[u8]) -> Self {
32        Self(format!("blake3:{}", blake3::hash(bytes).to_hex()))
33    }
34
35    /// Parse a previously displayed hash (`blake3:<hex>`).
36    ///
37    /// Returns `None` if the prefix or the hex payload is malformed.
38    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    /// The full `blake3:<hex>` string.
44    pub fn as_str(&self) -> &str {
45        &self.0
46    }
47
48    /// The first `n` hex characters of the digest, handy for short stable ids.
49    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/// Convert `\r\n` and lone `\r` to `\n`.
62pub(crate) fn normalise_newlines(text: &str) -> std::borrow::Cow<'_, str> {
63    if !text.contains('\r') {
64        return std::borrow::Cow::Borrowed(text);
65    }
66    std::borrow::Cow::Owned(text.replace("\r\n", "\n").replace('\r', "\n"))
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn parse_round_trips() {
75        let h = ContentHash::of_text("x");
76        assert_eq!(ContentHash::parse(h.as_str()), Some(h.clone()));
77        assert_eq!(ContentHash::parse("md5:abc"), None);
78        assert_eq!(h.short(8).len(), 8);
79    }
80}