Skip to main content

astrid_crypto/
hash.rs

1//! Content hashing using BLAKE3.
2//!
3//! Provides fast, cryptographic content hashing for audit chains,
4//! content verification, and integrity checking.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// A BLAKE3 content hash (32 bytes).
10///
11/// Used for:
12/// - Audit chain linking (each entry hashes the previous)
13/// - Content deduplication and verification
14/// - Binary hash verification before execution
15#[derive(Clone, Copy, PartialEq, Eq, Hash)]
16pub struct ContentHash([u8; 32]);
17
18impl ContentHash {
19    /// Hash arbitrary data.
20    #[must_use]
21    pub fn hash(data: &[u8]) -> Self {
22        Self(*blake3::hash(data).as_bytes())
23    }
24
25    /// Create a zero hash (used for genesis entries).
26    #[must_use]
27    pub const fn zero() -> Self {
28        Self([0u8; 32])
29    }
30
31    /// Check if this is the zero hash.
32    #[must_use]
33    pub fn is_zero(&self) -> bool {
34        self.0 == [0u8; 32]
35    }
36
37    /// Get the raw bytes.
38    #[must_use]
39    pub const fn as_bytes(&self) -> &[u8; 32] {
40        &self.0
41    }
42
43    /// Create from raw bytes.
44    #[must_use]
45    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
46        Self(bytes)
47    }
48
49    /// Try to create from a slice.
50    ///
51    /// Returns `None` if the slice is not exactly 32 bytes.
52    #[must_use]
53    pub fn try_from_slice(slice: &[u8]) -> Option<Self> {
54        if slice.len() != 32 {
55            return None;
56        }
57        let mut bytes = [0u8; 32];
58        bytes.copy_from_slice(slice);
59        Some(Self(bytes))
60    }
61
62    /// Encode as hex string.
63    #[must_use]
64    pub fn to_hex(&self) -> String {
65        hex::encode(self.0)
66    }
67
68    /// Decode from hex string.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if the string is not valid hex or not 32 bytes.
73    pub fn from_hex(s: &str) -> Result<Self, hex::FromHexError> {
74        let bytes = hex::decode(s)?;
75        Self::try_from_slice(&bytes).ok_or(hex::FromHexError::InvalidStringLength)
76    }
77
78    /// Encode as base64 string.
79    #[must_use]
80    pub fn to_base64(&self) -> String {
81        use base64::Engine;
82        base64::engine::general_purpose::STANDARD.encode(self.0)
83    }
84
85    /// Decode from base64 string.
86    ///
87    /// # Errors
88    ///
89    /// Returns an error if the string is not valid base64 or not 32 bytes.
90    pub fn from_base64(s: &str) -> Result<Self, base64::DecodeError> {
91        use base64::Engine;
92        let bytes = base64::engine::general_purpose::STANDARD.decode(s)?;
93        Self::try_from_slice(&bytes).ok_or(base64::DecodeError::InvalidLength(bytes.len()))
94    }
95}
96
97impl fmt::Debug for ContentHash {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        write!(f, "ContentHash({})", &self.to_hex()[..16])
100    }
101}
102
103impl fmt::Display for ContentHash {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        write!(f, "{}", self.to_hex())
106    }
107}
108
109impl Serialize for ContentHash {
110    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
111    where
112        S: serde::Serializer,
113    {
114        serializer.serialize_str(&self.to_hex())
115    }
116}
117
118impl<'de> Deserialize<'de> for ContentHash {
119    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
120    where
121        D: serde::Deserializer<'de>,
122    {
123        let s = String::deserialize(deserializer)?;
124        Self::from_hex(&s).map_err(serde::de::Error::custom)
125    }
126}
127
128impl Default for ContentHash {
129    fn default() -> Self {
130        Self::zero()
131    }
132}
133
134impl AsRef<[u8]> for ContentHash {
135    fn as_ref(&self) -> &[u8] {
136        &self.0
137    }
138}
139
140impl From<[u8; 32]> for ContentHash {
141    fn from(bytes: [u8; 32]) -> Self {
142        Self(bytes)
143    }
144}
145
146impl From<ContentHash> for [u8; 32] {
147    fn from(hash: ContentHash) -> Self {
148        hash.0
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_hash_basic() {
158        let data = b"hello world";
159        let hash = ContentHash::hash(data);
160
161        // Same data produces same hash
162        assert_eq!(hash, ContentHash::hash(data));
163
164        // Different data produces different hash
165        assert_ne!(hash, ContentHash::hash(b"different"));
166    }
167
168    #[test]
169    fn test_zero_hash() {
170        let zero = ContentHash::zero();
171        assert!(zero.is_zero());
172        assert!(!ContentHash::hash(b"data").is_zero());
173    }
174
175    #[test]
176    fn test_hex_encoding() {
177        let hash = ContentHash::hash(b"test");
178        let hex = hash.to_hex();
179        let decoded = ContentHash::from_hex(&hex).unwrap();
180        assert_eq!(hash, decoded);
181    }
182
183    #[test]
184    fn test_base64_encoding() {
185        let hash = ContentHash::hash(b"test");
186        let b64 = hash.to_base64();
187        let decoded = ContentHash::from_base64(&b64).unwrap();
188        assert_eq!(hash, decoded);
189    }
190
191    #[test]
192    fn test_serde() {
193        let hash = ContentHash::hash(b"test");
194        let json = serde_json::to_string(&hash).unwrap();
195        let decoded: ContentHash = serde_json::from_str(&json).unwrap();
196        assert_eq!(hash, decoded);
197    }
198}