Skip to main content

choir_hash/
lib.rs

1//! Self-describing content addressing (DECISIONS.md D6).
2//!
3//! The codec byte names the hash function, so a future hash migration adds a
4//! codec instead of rewriting stored identifiers. Shared by L0 (store) and
5//! L1 (op log).
6//!
7//! # Examples
8//!
9//! ```
10//! use choir_hash::ContentHash;
11//!
12//! let h = ContentHash::blake3(b"hello");
13//! assert_eq!(h.codec, 0x1e); // BLAKE3-256 per the multicodec table
14//! assert_eq!(h.digest.len(), 32);
15//! assert_eq!(h, ContentHash::blake3(b"hello"));
16//! ```
17//!
18//! # Where this sits
19//!
20//! `docs/architecture.md` is the map of the whole workspace.
21//! This crate is the bottom of the stack: self-describing content addressing (D6), shared by L0 and L1.
22//!
23//! It depends on no other crate in this workspace.
24
25use serde::{Deserialize, Serialize};
26
27/// Self-describing content address (multihash-style envelope).
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
29pub struct ContentHash {
30    /// Hash-function identifier; `0x1e` = BLAKE3-256, following the
31    /// multicodec table.
32    pub codec: u8,
33    /// Raw digest bytes for `codec`.
34    pub digest: Vec<u8>,
35}
36
37impl ContentHash {
38    /// Hashes `data` with BLAKE3-256 and wraps it in the envelope.
39    pub fn blake3(data: &[u8]) -> Self {
40        Self {
41            codec: 0x1e,
42            digest: blake3::hash(data).as_bytes().to_vec(),
43        }
44    }
45
46    /// Wraps a git object id (hex) in the envelope: codec `0x11` for
47    /// SHA-1 (40 hex chars) or `0x12` for SHA-256 (64), per the
48    /// multicodec table. `None` for anything else. This is how git ref
49    /// updates enter the op log without pretending to be BLAKE3.
50    pub fn from_git_oid(hex: &str) -> Option<Self> {
51        let codec = match hex.len() {
52            40 => 0x11,
53            64 => 0x12,
54            _ => return None,
55        };
56        let digest: Option<Vec<u8>> = (0..hex.len())
57            .step_by(2)
58            .map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok())
59            .collect();
60        Some(Self {
61            codec,
62            digest: digest?,
63        })
64    }
65
66    /// The git object id this envelope carries, or `None` if it is not a
67    /// git hash. The inverse of [`ContentHash::from_git_oid`], for the
68    /// paths that have to hand an oid back to git itself.
69    pub fn git_oid(&self) -> Option<String> {
70        if self.codec != 0x11 && self.codec != 0x12 {
71            return None;
72        }
73        let mut s = String::with_capacity(self.digest.len() * 2);
74        for b in &self.digest {
75            s.push_str(&format!("{b:02x}"));
76        }
77        Some(s)
78    }
79
80    /// Parses what [`ContentHash::to_hex`] produced. `None` for anything
81    /// else, including a bare digest with no codec prefix.
82    ///
83    /// Its only caller is a lookup that reads a key id back out of a
84    /// hex-keyed map and has to put it in a persisted record. That is
85    /// also the reason it refuses an unprefixed digest rather than
86    /// guessing a codec: inventing the byte that says which hash function
87    /// this is would defeat the envelope (D6).
88    pub fn from_hex(text: &str) -> Option<Self> {
89        let (codec, digest) = text.split_once('-')?;
90        if codec.len() != 2 || digest.is_empty() || digest.len() % 2 != 0 {
91            return None;
92        }
93        let digest: Option<Vec<u8>> = (0..digest.len())
94            .step_by(2)
95            .map(|i| u8::from_str_radix(digest.get(i..i + 2)?, 16).ok())
96            .collect();
97        Some(Self {
98            codec: u8::from_str_radix(codec, 16).ok()?,
99            digest: digest?,
100        })
101    }
102
103    /// Lowercase hex of the digest, prefixed with the codec byte
104    /// (e.g. `1e-ab12…`); used for filesystem sharding and display.
105    pub fn to_hex(&self) -> String {
106        let mut s = format!("{:02x}-", self.codec);
107        for b in &self.digest {
108            s.push_str(&format!("{b:02x}"));
109        }
110        s
111    }
112}