use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct ContentHash(String);
impl<'de> Deserialize<'de> for ContentHash {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let text = String::deserialize(deserializer)?;
Self::parse(&text).ok_or_else(|| serde::de::Error::custom("expected blake3:<64 hex digits>"))
}
}
impl ContentHash {
pub fn of_text(text: &str) -> Self {
let normalised = normalise_newlines(text);
Self::of_bytes(normalised.as_bytes())
}
pub fn of_bytes(bytes: &[u8]) -> Self {
Self(format!("blake3:{}", blake3::hash(bytes).to_hex()))
}
pub fn parse(s: &str) -> Option<Self> {
let hex = s.strip_prefix("blake3:")?;
(hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit())).then(|| Self(s.to_ascii_lowercase()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn short(&self, n: usize) -> &str {
let hex = &self.0["blake3:".len()..];
&hex[..n.min(hex.len())]
}
}
impl fmt::Display for ContentHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct Fingerprint([u8; 16]);
impl Fingerprint {
pub const PREFIX: &'static str = "blake3-16:";
pub const fn from_bytes(bytes: [u8; 16]) -> Self {
Self(bytes)
}
pub fn from_blake3(hash: &blake3::Hash) -> Self {
let mut b = [0u8; 16];
b.copy_from_slice(&hash.as_bytes()[..16]);
Self(b)
}
pub fn as_bytes(&self) -> &[u8; 16] {
&self.0
}
pub fn is_unset(&self) -> bool {
self.0 == [0; 16]
}
pub fn parse(s: &str) -> Option<Self> {
let hex = s.strip_prefix(Self::PREFIX)?;
if hex.len() != 32 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let mut b = [0u8; 16];
for (i, out) in b.iter_mut().enumerate() {
*out = u8::from_str_radix(hex.get(2 * i..2 * i + 2)?, 16).ok()?;
}
Some(Self(b))
}
pub fn merge(self, other: Self) -> Self {
let (lo, hi) = if self <= other { (self, other) } else { (other, self) };
let mut h = blake3::Hasher::new_derive_key("sealmap sm1 2026-10 merged twin fingerprints");
h.update(&lo.0);
h.update(&hi.0);
Self::from_blake3(&h.finalize())
}
}
impl fmt::Display for Fingerprint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(Self::PREFIX)?;
for b in &self.0 {
write!(f, "{b:02x}")?;
}
Ok(())
}
}
impl Serialize for Fingerprint {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Fingerprint {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::parse(&s).ok_or_else(|| serde::de::Error::custom(format!("invalid fingerprint `{s}`")))
}
}
pub(crate) fn normalise_newlines(text: &str) -> std::borrow::Cow<'_, str> {
if !text.contains('\r') {
return std::borrow::Cow::Borrowed(text);
}
std::borrow::Cow::Owned(text.replace("\r\n", "\n").replace('\r', "\n"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fingerprint_rejects_signed_byte_pairs_and_accepts_hex() {
for pair in ["+0", "+a", "-0", " g"] {
let text = format!("blake3-16:{}", pair.repeat(16));
assert!(Fingerprint::parse(&text).is_none(), "{text}");
}
for pair in ["00", "af", "AF"] {
let text = format!("blake3-16:{}", pair.repeat(16));
assert!(Fingerprint::parse(&text).is_some(), "{text}");
}
}
#[test]
fn parse_round_trips() {
let h = ContentHash::of_text("x");
assert_eq!(ContentHash::parse(h.as_str()), Some(h.clone()));
assert_eq!(ContentHash::parse("md5:abc"), None);
assert_eq!(h.short(8).len(), 8);
}
#[test]
fn deserialisation_preserves_the_hash_invariant() {
for bad in ["", "x", "md5:abc", "blake3:abc", "blake3:🦀"] {
let json = serde_json::to_string(bad).unwrap();
assert!(serde_json::from_str::<ContentHash>(&json).is_err(), "{bad:?}");
}
let hash = ContentHash::of_text("receipt");
let parsed: ContentHash = serde_json::from_str(&serde_json::to_string(&hash).unwrap()).unwrap();
assert_eq!(parsed.short(999), &hash.as_str()[7..]);
}
}