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)]
158#[non_exhaustive]
159pub enum EventHashParseError {
160    /// The input string was not exactly 40 hex characters.
161    #[error("expected 40 hex characters, got {0}")]
162    InvalidLength(usize),
163    /// The input contained a non-hex character at the given position.
164    #[error("invalid hex character at position {position}: {ch:?}")]
165    InvalidChar {
166        /// Zero-based index of the first invalid character.
167        position: usize,
168        /// The character that failed hex decoding.
169        ch: char,
170    },
171}
172
173impl FromStr for EventHash {
174    type Err = EventHashParseError;
175
176    fn from_str(s: &str) -> Result<Self, Self::Err> {
177        if s.len() != 40 {
178            return Err(EventHashParseError::InvalidLength(s.len()));
179        }
180        let mut bytes = [0u8; 20];
181        for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
182            let hi = hex_digit(chunk[0]).ok_or(EventHashParseError::InvalidChar {
183                position: i * 2,
184                ch: chunk[0] as char,
185            })?;
186            let lo = hex_digit(chunk[1]).ok_or(EventHashParseError::InvalidChar {
187                position: i * 2 + 1,
188                ch: chunk[1] as char,
189            })?;
190            bytes[i] = (hi << 4) | lo;
191        }
192        Ok(Self(bytes))
193    }
194}
195
196impl Serialize for EventHash {
197    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
198        serializer.serialize_str(&self.to_hex())
199    }
200}
201
202impl<'de> Deserialize<'de> for EventHash {
203    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
204        let s = String::deserialize(deserializer)?;
205        s.parse::<EventHash>().map_err(de::Error::custom)
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn from_bytes_roundtrip() {
215        let bytes = [
216            1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
217        ];
218        let hash = EventHash::from_bytes(bytes);
219        assert_eq!(hash.as_bytes(), &bytes);
220    }
221
222    #[test]
223    fn from_hex_valid() {
224        let hash = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
225        let mut expected = [0u8; 20];
226        expected[19] = 1;
227        assert_eq!(hash.as_bytes(), &expected);
228    }
229
230    #[test]
231    fn from_hex_all_zeros() {
232        let hash = EventHash::from_hex("0000000000000000000000000000000000000000").unwrap();
233        assert_eq!(hash.as_bytes(), &[0u8; 20]);
234    }
235
236    #[test]
237    fn from_hex_uppercase() {
238        let hash = EventHash::from_hex("ABCDEF0123456789ABCDEF0123456789ABCDEF01").unwrap();
239        assert!(
240            hash.to_hex()
241                .chars()
242                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
243        );
244    }
245
246    #[test]
247    fn from_hex_wrong_length() {
248        assert!(EventHash::from_hex("0123").is_none());
249        assert!(EventHash::from_hex("").is_none());
250        assert!(EventHash::from_hex("00000000000000000000000000000000000000001").is_none());
251    }
252
253    #[test]
254    fn from_hex_invalid_chars() {
255        assert!(EventHash::from_hex("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz").is_none());
256        assert!(EventHash::from_hex("0000000000000000000000000000000000000g01").is_none());
257    }
258
259    #[test]
260    fn to_hex_roundtrip() {
261        let original = "0123456789abcdef0123456789abcdef01234567";
262        let hash = EventHash::from_hex(original).unwrap();
263        assert_eq!(hash.to_hex(), original);
264    }
265
266    #[test]
267    fn debug_format() {
268        let hash = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
269        let debug = format!("{:?}", hash);
270        assert!(debug.contains("EventHash"));
271        assert!(debug.contains("0000000000000000000000000000000000000001"));
272    }
273
274    #[test]
275    fn display_format() {
276        let hash = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
277        assert_eq!(
278            format!("{}", hash),
279            "0000000000000000000000000000000000000001"
280        );
281    }
282
283    #[test]
284    fn equality() {
285        let a = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
286        let b = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
287        let c = EventHash::from_hex("0000000000000000000000000000000000000002").unwrap();
288
289        assert_eq!(a, b);
290        assert_ne!(a, c);
291    }
292
293    #[test]
294    fn hash_trait() {
295        use std::collections::HashSet;
296
297        let mut set = HashSet::new();
298        let a = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
299        let b = EventHash::from_hex("0000000000000000000000000000000000000002").unwrap();
300
301        set.insert(a);
302        set.insert(b);
303        set.insert(a); // duplicate
304
305        assert_eq!(set.len(), 2);
306    }
307}