Skip to main content

boundgraph_core/
digest.rs

1//! Content-addressed digests.
2
3use std::fmt;
4use std::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7
8/// Width of a [`Digest`] in bytes.
9pub const DIGEST_LEN: usize = 32;
10
11/// A `blake3` digest of some canonical encoding.
12///
13/// This is the identity of every immutable primitive in BoundGraph. It is what makes
14/// the ledger verifiable by a third party and what makes the content-addressed
15/// store able to deduplicate: equal digests mean equal bytes, with no further
16/// comparison needed.
17#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
18#[serde(transparent)]
19pub struct Digest(#[serde(with = "serde_bytes_32")] [u8; DIGEST_LEN]);
20
21impl Digest {
22    /// Hash raw bytes.
23    ///
24    /// Prefer [`Canonical::digest`](crate::Canonical::digest) for typed values;
25    /// this is for bytes that are already in their final form, such as a raw
26    /// tool output on its way into the content-addressed store.
27    pub fn of(bytes: &[u8]) -> Self {
28        Self(*blake3::hash(bytes).as_bytes())
29    }
30
31    /// Wrap an already-computed digest.
32    pub const fn from_bytes(bytes: [u8; DIGEST_LEN]) -> Self {
33        Self(bytes)
34    }
35
36    /// The raw digest bytes.
37    pub const fn as_bytes(&self) -> &[u8; DIGEST_LEN] {
38        &self.0
39    }
40
41    /// Lowercase hex, 64 characters.
42    pub fn to_hex(&self) -> String {
43        use fmt::Write as _;
44        self.0.iter().fold(String::with_capacity(64), |mut s, b| {
45            let _ = write!(s, "{b:02x}");
46            s
47        })
48    }
49
50    /// The first `n` hex characters, for logs and diagnostics.
51    ///
52    /// Never use an abbreviated digest for identity or comparison.
53    pub fn short(&self, n: usize) -> String {
54        let hex = self.to_hex();
55        hex[..n.min(hex.len())].to_string()
56    }
57}
58
59/// A digest was not valid lowercase hex of the right length.
60#[derive(Debug, thiserror::Error)]
61pub enum DigestParseError {
62    /// Wrong number of characters.
63    #[error("expected {expected} hex characters, found {found}")]
64    Length {
65        /// Characters required.
66        expected: usize,
67        /// Characters supplied.
68        found: usize,
69    },
70
71    /// A character was not a hex digit.
72    #[error("invalid hex character {ch:?} at position {index}")]
73    Char {
74        /// The offending character.
75        ch: char,
76        /// Its index in the input.
77        index: usize,
78    },
79}
80
81impl FromStr for Digest {
82    type Err = DigestParseError;
83
84    fn from_str(s: &str) -> Result<Self, Self::Err> {
85        if s.len() != DIGEST_LEN * 2 {
86            return Err(DigestParseError::Length {
87                expected: DIGEST_LEN * 2,
88                found: s.len(),
89            });
90        }
91        let mut out = [0u8; DIGEST_LEN];
92        for (i, byte) in out.iter_mut().enumerate() {
93            let pair = &s[i * 2..i * 2 + 2];
94            *byte = u8::from_str_radix(pair, 16).map_err(|_| {
95                let (index, ch) = pair
96                    .char_indices()
97                    .find(|(_, c)| !c.is_ascii_hexdigit())
98                    .map(|(j, c)| (i * 2 + j, c))
99                    .unwrap_or((i * 2, pair.chars().next().unwrap_or('?')));
100                DigestParseError::Char { ch, index }
101            })?;
102        }
103        Ok(Self(out))
104    }
105}
106
107impl fmt::Display for Digest {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.write_str(&self.to_hex())
110    }
111}
112
113impl fmt::Debug for Digest {
114    /// Abbreviated, so debug output of a struct full of digests stays readable.
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        write!(f, "Digest({}…)", self.short(12))
117    }
118}
119
120/// Fixed-width byte arrays are not `serde`-native, and the right encoding
121/// differs by format.
122///
123/// **Binary formats get a byte string.** CBOR is what [`Canonical`] uses, and a
124/// byte string rather than an array of 32 integers is what keeps the ledger a
125/// quarter of the size. This encoding is part of the digest's *identity*: change
126/// it and every content address in the system changes, so
127/// `the_canonical_encoding_of_a_digest_is_fixed` pins it.
128///
129/// **Human-readable formats get hex.** A digest in a JSONL ledger has to be
130/// greppable, comparable by eye, and the same 64 characters that
131/// [`Display`](fmt::Display) prints — US-1002 requires the file to be readable
132/// with `jq` and standard line tools, and `[229,197,127,…]` is readable by a
133/// parser rather than by a person.
134///
135/// `serde` reports which kind of format it is talking to, so both are served
136/// without either compromising.
137mod serde_bytes_32 {
138    use super::DIGEST_LEN;
139    use serde::de::{Error as _, Unexpected};
140    use serde::{Deserialize, Deserializer, Serializer};
141
142    pub(super) fn serialize<S: Serializer>(
143        bytes: &[u8; DIGEST_LEN],
144        s: S,
145    ) -> Result<S::Ok, S::Error> {
146        if s.is_human_readable() {
147            return s.serialize_str(&super::Digest(*bytes).to_hex());
148        }
149        s.serialize_bytes(bytes)
150    }
151
152    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
153        d: D,
154    ) -> Result<[u8; DIGEST_LEN], D::Error> {
155        if d.is_human_readable() {
156            // The same parser `FromStr` uses, so a digest read back from a
157            // ledger and one parsed from a command line cannot disagree.
158            let hex = String::deserialize(d)?;
159            return hex
160                .parse::<super::Digest>()
161                .map(|digest| *digest.as_bytes())
162                .map_err(|_| D::Error::invalid_value(Unexpected::Str(&hex), &"64 hex characters"));
163        }
164
165        let raw = serde_bytes_helper::ByteBuf::deserialize(d)?;
166        <[u8; DIGEST_LEN]>::try_from(raw.0.as_slice())
167            .map_err(|_| D::Error::invalid_value(Unexpected::Bytes(&raw.0), &"exactly 32 bytes"))
168    }
169
170    /// Minimal stand-in for `serde_bytes`, to avoid the extra dependency.
171    mod serde_bytes_helper {
172        use serde::de::{Deserialize, Deserializer, SeqAccess, Visitor};
173        use std::fmt;
174
175        pub(super) struct ByteBuf(pub(super) Vec<u8>);
176
177        impl<'de> Deserialize<'de> for ByteBuf {
178            fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
179                struct V;
180                impl<'de> Visitor<'de> for V {
181                    type Value = ByteBuf;
182                    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183                        f.write_str("a byte string")
184                    }
185                    fn visit_bytes<E: serde::de::Error>(self, v: &[u8]) -> Result<ByteBuf, E> {
186                        Ok(ByteBuf(v.to_vec()))
187                    }
188                    fn visit_byte_buf<E: serde::de::Error>(self, v: Vec<u8>) -> Result<ByteBuf, E> {
189                        Ok(ByteBuf(v))
190                    }
191                    fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<ByteBuf, A::Error> {
192                        let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(32));
193                        while let Some(b) = seq.next_element::<u8>()? {
194                            out.push(b);
195                        }
196                        Ok(ByteBuf(out))
197                    }
198                    fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<ByteBuf, E> {
199                        Err(E::custom(format!("expected bytes, found string {v:?}")))
200                    }
201                }
202                d.deserialize_bytes(V)
203            }
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn hex_is_lowercase_and_64_chars() {
214        let d = Digest::of(b"boundgraph");
215        let hex = d.to_hex();
216        assert_eq!(hex.len(), 64);
217        assert_eq!(hex, hex.to_lowercase());
218        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
219    }
220
221    #[test]
222    fn hex_round_trips() {
223        let d = Digest::of(b"boundgraph");
224        assert_eq!(Digest::from_str(&d.to_hex()).unwrap(), d);
225    }
226
227    /// A digest's canonical encoding *is* part of its identity: change it and
228    /// every content address in the system changes, silently and everywhere.
229    /// So the bytes are pinned rather than merely round-tripped.
230    ///
231    /// CBOR major type 2 (byte string), one-byte length: `0x58 0x20`, then the
232    /// 32 bytes themselves.
233    #[test]
234    fn the_canonical_encoding_of_a_digest_is_fixed() {
235        let digest = Digest::of(b"boundgraph");
236        let encoded = crate::to_canonical_cbor(&digest).unwrap();
237
238        assert_eq!(encoded.len(), 34, "a two-byte header and 32 bytes");
239        assert_eq!(encoded[0], 0x58, "byte string, one-byte length");
240        assert_eq!(encoded[1], 0x20, "32 bytes long");
241        assert_eq!(&encoded[2..], digest.as_bytes());
242
243        let back: Digest = crate::from_canonical_cbor(&encoded).unwrap();
244        assert_eq!(back, digest);
245    }
246
247    /// The criterion behind US-1002: a ledger has to be greppable. In a
248    /// human-readable format a digest is the same 64 characters `Display`
249    /// prints, not an array of 32 numbers.
250    #[test]
251    fn a_digest_reads_as_hex_in_a_human_readable_format() {
252        let digest = Digest::of(b"boundgraph");
253        let json = serde_json::to_string(&digest).unwrap();
254
255        assert_eq!(json, format!("\"{}\"", digest.to_hex()));
256        assert!(
257            json.contains(&digest.to_hex()),
258            "so `grep <digest>` finds the line that mentions it"
259        );
260
261        let back: Digest = serde_json::from_str(&json).unwrap();
262        assert_eq!(back, digest);
263    }
264
265    #[test]
266    fn a_malformed_hex_digest_is_refused_rather_than_truncated() {
267        for bad in ["\"\"", "\"nothex\"", "\"ab\"", "\"zz00\""] {
268            assert!(
269                serde_json::from_str::<Digest>(bad).is_err(),
270                "{bad} must not parse as a digest"
271            );
272        }
273    }
274
275    #[test]
276    fn hashing_is_stable_and_input_sensitive() {
277        assert_eq!(Digest::of(b"a"), Digest::of(b"a"));
278        assert_ne!(Digest::of(b"a"), Digest::of(b"b"));
279    }
280
281    #[test]
282    fn rejects_bad_hex() {
283        assert!(matches!(
284            Digest::from_str("abc"),
285            Err(DigestParseError::Length { .. })
286        ));
287        let bad = "z".repeat(64);
288        assert!(matches!(
289            Digest::from_str(&bad),
290            Err(DigestParseError::Char { .. })
291        ));
292    }
293
294    #[test]
295    fn debug_is_abbreviated_but_display_is_not() {
296        let d = Digest::of(b"boundgraph");
297        assert!(format!("{d:?}").len() < d.to_string().len());
298        assert_eq!(d.to_string(), d.to_hex());
299    }
300}