Skip to main content

auths_core/witness/
hash.rs

1//! Backend-agnostic event hash type.
2//!
3//! This module provides [`EventHash`], a 20-byte hash type used to identify
4//! KEL events without depending on any specific storage backend (e.g., git2).
5//!
6//! # Why 20 Bytes?
7//!
8//! Git uses SHA-1 (20 bytes) for object identifiers. This type is sized to
9//! be compatible with Git OIDs while remaining backend-agnostic.
10
11use std::fmt;
12use std::str::FromStr;
13
14use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
15
16/// A 20-byte hash identifying a KEL event.
17///
18/// Serializes as a 40-character lowercase hex string, matching the encoding
19/// used by `git2::Oid::to_string()`. This ensures JSON payloads, API schemas,
20/// and cache files remain compatible when migrating from `git2::Oid`.
21///
22/// # Args
23///
24/// The inner `[u8; 20]` represents the raw SHA-1 bytes.
25///
26/// # Usage
27///
28/// ```rust
29/// use auths_core::witness::EventHash;
30///
31/// // From raw bytes
32/// let bytes = [0u8; 20];
33/// let hash = EventHash::from_bytes(bytes);
34/// assert_eq!(hash.as_bytes(), &bytes);
35///
36/// // From hex string
37/// let hash = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
38/// assert_eq!(hash.to_hex(), "0000000000000000000000000000000000000001");
39///
40/// // Serde: serializes as hex string, not integer array
41/// let json = serde_json::to_string(&hash).unwrap();
42/// assert_eq!(json, r#""0000000000000000000000000000000000000001""#);
43/// ```
44#[derive(Clone, Copy, PartialEq, Eq, Hash)]
45pub struct EventHash([u8; 20]);
46
47impl EventHash {
48    /// Create an EventHash from raw bytes.
49    #[inline]
50    pub const fn from_bytes(bytes: [u8; 20]) -> Self {
51        Self(bytes)
52    }
53
54    /// Get the raw bytes of this hash.
55    #[inline]
56    pub fn as_bytes(&self) -> &[u8; 20] {
57        &self.0
58    }
59
60    /// Create an EventHash from a hex string.
61    ///
62    /// Returns `None` if the string is not exactly 40 hex characters.
63    ///
64    /// # Example
65    ///
66    /// ```rust
67    /// use auths_core::witness::EventHash;
68    ///
69    /// let hash = EventHash::from_hex("0123456789abcdef0123456789abcdef01234567");
70    /// assert!(hash.is_some());
71    ///
72    /// // Wrong length
73    /// assert!(EventHash::from_hex("0123").is_none());
74    ///
75    /// // Invalid characters
76    /// assert!(EventHash::from_hex("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").is_none());
77    /// ```
78    pub fn from_hex(s: &str) -> Option<Self> {
79        if s.len() != 40 {
80            return None;
81        }
82
83        let mut bytes = [0u8; 20];
84        for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
85            let hi = hex_digit(chunk[0])?;
86            let lo = hex_digit(chunk[1])?;
87            bytes[i] = (hi << 4) | lo;
88        }
89
90        Some(Self(bytes))
91    }
92
93    /// Convert this hash to a lowercase hex string.
94    ///
95    /// # Example
96    ///
97    /// ```rust
98    /// use auths_core::witness::EventHash;
99    ///
100    /// let hash = EventHash::from_bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
101    ///                                   10, 11, 12, 13, 14, 15, 16, 17, 18, 19]);
102    /// assert_eq!(hash.to_hex(), "000102030405060708090a0b0c0d0e0f10111213");
103    /// ```
104    pub fn to_hex(&self) -> String {
105        let mut s = String::with_capacity(40);
106        for byte in &self.0 {
107            s.push(HEX_CHARS[(byte >> 4) as usize]);
108            s.push(HEX_CHARS[(byte & 0xf) as usize]);
109        }
110        s
111    }
112}
113
114/// Hex characters for encoding.
115const HEX_CHARS: [char; 16] = [
116    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
117];
118
119/// Convert a hex character to its numeric value.
120#[inline]
121fn hex_digit(c: u8) -> Option<u8> {
122    match c {
123        b'0'..=b'9' => Some(c - b'0'),
124        b'a'..=b'f' => Some(c - b'a' + 10),
125        b'A'..=b'F' => Some(c - b'A' + 10),
126        _ => None,
127    }
128}
129
130impl fmt::Debug for EventHash {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        write!(f, "EventHash({})", self.to_hex())
133    }
134}
135
136impl fmt::Display for EventHash {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        write!(f, "{}", self.to_hex())
139    }
140}
141
142/// Error returned when parsing an `EventHash` from a hex string fails.
143///
144/// # Args
145///
146/// * `InvalidLength` — the input was not exactly 40 hex characters
147/// * `InvalidChar` — the input contained a non-hex character
148///
149/// # Usage
150///
151/// ```rust
152/// use auths_core::witness::EventHash;
153/// use std::str::FromStr;
154///
155/// assert!(EventHash::from_str("not-hex").is_err());
156/// ```
157#[derive(Debug, thiserror::Error, PartialEq)]
158pub enum EventHashParseError {
159    /// The input string was not exactly 40 hex characters.
160    #[error("expected 40 hex characters, got {0}")]
161    InvalidLength(usize),
162    /// The input contained a non-hex character at the given position.
163    #[error("invalid hex character at position {position}: {ch:?}")]
164    InvalidChar {
165        /// Zero-based index of the first invalid character.
166        position: usize,
167        /// The character that failed hex decoding.
168        ch: char,
169    },
170}
171
172impl FromStr for EventHash {
173    type Err = EventHashParseError;
174
175    fn from_str(s: &str) -> Result<Self, Self::Err> {
176        if s.len() != 40 {
177            return Err(EventHashParseError::InvalidLength(s.len()));
178        }
179        let mut bytes = [0u8; 20];
180        for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
181            let hi = hex_digit(chunk[0]).ok_or(EventHashParseError::InvalidChar {
182                position: i * 2,
183                ch: chunk[0] as char,
184            })?;
185            let lo = hex_digit(chunk[1]).ok_or(EventHashParseError::InvalidChar {
186                position: i * 2 + 1,
187                ch: chunk[1] as char,
188            })?;
189            bytes[i] = (hi << 4) | lo;
190        }
191        Ok(Self(bytes))
192    }
193}
194
195impl Serialize for EventHash {
196    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
197        serializer.serialize_str(&self.to_hex())
198    }
199}
200
201impl<'de> Deserialize<'de> for EventHash {
202    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
203        let s = String::deserialize(deserializer)?;
204        s.parse::<EventHash>().map_err(de::Error::custom)
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn from_bytes_roundtrip() {
214        let bytes = [
215            1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
216        ];
217        let hash = EventHash::from_bytes(bytes);
218        assert_eq!(hash.as_bytes(), &bytes);
219    }
220
221    #[test]
222    fn from_hex_valid() {
223        let hash = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
224        let mut expected = [0u8; 20];
225        expected[19] = 1;
226        assert_eq!(hash.as_bytes(), &expected);
227    }
228
229    #[test]
230    fn from_hex_all_zeros() {
231        let hash = EventHash::from_hex("0000000000000000000000000000000000000000").unwrap();
232        assert_eq!(hash.as_bytes(), &[0u8; 20]);
233    }
234
235    #[test]
236    fn from_hex_uppercase() {
237        let hash = EventHash::from_hex("ABCDEF0123456789ABCDEF0123456789ABCDEF01").unwrap();
238        assert!(
239            hash.to_hex()
240                .chars()
241                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
242        );
243    }
244
245    #[test]
246    fn from_hex_wrong_length() {
247        assert!(EventHash::from_hex("0123").is_none());
248        assert!(EventHash::from_hex("").is_none());
249        assert!(EventHash::from_hex("00000000000000000000000000000000000000001").is_none());
250    }
251
252    #[test]
253    fn from_hex_invalid_chars() {
254        assert!(EventHash::from_hex("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").is_none());
255        assert!(EventHash::from_hex("0000000000000000000000000000000000000g01").is_none());
256    }
257
258    #[test]
259    fn to_hex_roundtrip() {
260        let original = "0123456789abcdef0123456789abcdef01234567";
261        let hash = EventHash::from_hex(original).unwrap();
262        assert_eq!(hash.to_hex(), original);
263    }
264
265    #[test]
266    fn debug_format() {
267        let hash = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
268        let debug = format!("{:?}", hash);
269        assert!(debug.contains("EventHash"));
270        assert!(debug.contains("0000000000000000000000000000000000000001"));
271    }
272
273    #[test]
274    fn display_format() {
275        let hash = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
276        assert_eq!(
277            format!("{}", hash),
278            "0000000000000000000000000000000000000001"
279        );
280    }
281
282    #[test]
283    fn equality() {
284        let a = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
285        let b = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
286        let c = EventHash::from_hex("0000000000000000000000000000000000000002").unwrap();
287
288        assert_eq!(a, b);
289        assert_ne!(a, c);
290    }
291
292    #[test]
293    fn hash_trait() {
294        use std::collections::HashSet;
295
296        let mut set = HashSet::new();
297        let a = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
298        let b = EventHash::from_hex("0000000000000000000000000000000000000002").unwrap();
299
300        set.insert(a);
301        set.insert(b);
302        set.insert(a); // duplicate
303
304        assert_eq!(set.len(), 2);
305    }
306}