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}