Skip to main content

shardline_protocol/
hash.rs

1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3
4const HASH_BYTE_LENGTH: usize = 32;
5const HASH_HEX_LENGTH: usize = 64;
6
7/// A 32-byte protocol hash.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9pub struct ShardlineHash {
10    bytes: [u8; HASH_BYTE_LENGTH],
11}
12
13impl ShardlineHash {
14    /// Creates a hash from raw bytes.
15    #[must_use]
16    pub const fn from_bytes(bytes: [u8; HASH_BYTE_LENGTH]) -> Self {
17        Self { bytes }
18    }
19
20    /// Returns the raw hash bytes.
21    #[must_use]
22    pub const fn as_bytes(&self) -> &[u8; HASH_BYTE_LENGTH] {
23        &self.bytes
24    }
25
26    /// Parses a hash from canonical lowercase hexadecimal text.
27    ///
28    /// # Errors
29    ///
30    /// Returns [`HashParseError`] when the string has the wrong length, contains
31    /// non-lowercase hexadecimal characters, or cannot be decoded into 32 bytes.
32    pub fn parse_hex(value: &str) -> Result<Self, HashParseError> {
33        if value.len() != HASH_HEX_LENGTH {
34            return Err(HashParseError::InvalidLength);
35        }
36
37        if !value
38            .bytes()
39            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
40        {
41            return Err(HashParseError::InvalidCharacter(
42                "non-lowercase hexadecimal character".to_owned(),
43            ));
44        }
45
46        let decoded =
47            hex::decode(value).map_err(|e| HashParseError::InvalidCharacter(e.to_string()))?;
48        let bytes = <[u8; HASH_BYTE_LENGTH]>::try_from(decoded).map_err(|vec| {
49            let _ = vec;
50            HashParseError::InvalidLength
51        })?;
52
53        Ok(Self { bytes })
54    }
55
56    /// Returns canonical lowercase hexadecimal text.
57    #[must_use]
58    pub fn hex_string(&self) -> String {
59        let mut encoded = Vec::with_capacity(HASH_HEX_LENGTH);
60        for byte in self.bytes {
61            append_lower_hex_byte(&mut encoded, byte);
62        }
63
64        String::from_utf8(encoded).unwrap_or_default()
65    }
66}
67
68fn append_lower_hex_byte(output: &mut Vec<u8>, byte: u8) {
69    output.push(lower_hex_digit(byte >> 4));
70    output.push(lower_hex_digit(byte & 0x0f));
71}
72
73const fn lower_hex_digit(nibble: u8) -> u8 {
74    match nibble {
75        0 => b'0',
76        1 => b'1',
77        2 => b'2',
78        3 => b'3',
79        4 => b'4',
80        5 => b'5',
81        6 => b'6',
82        7 => b'7',
83        8 => b'8',
84        9 => b'9',
85        10 => b'a',
86        11 => b'b',
87        12 => b'c',
88        13 => b'd',
89        14 => b'e',
90        _ => b'f',
91    }
92}
93
94/// Hash parsing failure.
95#[derive(Debug, Clone, Error)]
96pub enum HashParseError {
97    /// The hash string did not contain exactly 64 hexadecimal characters.
98    #[error("hash must contain exactly 64 lowercase hexadecimal characters")]
99    InvalidLength,
100    /// The hash string contained a character outside lowercase hexadecimal, or hex decoding failed.
101    #[error("invalid hash character: {0}")]
102    InvalidCharacter(String),
103}
104
105#[cfg(test)]
106mod tests {
107    use super::{HashParseError, ShardlineHash};
108
109    #[test]
110    fn canonical_hash_hex_round_trips_raw_bytes() {
111        let hash = ShardlineHash::from_bytes([
112            0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23,
113            24, 25, 26, 27, 28, 29, 30, 31,
114        ]);
115
116        let hex = hash.hex_string();
117
118        assert_eq!(
119            hex,
120            "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
121        );
122        let parsed = ShardlineHash::parse_hex(&hex);
123        assert_eq!(parsed.unwrap(), hash);
124    }
125
126    #[test]
127    fn canonical_hash_vectors_round_trip() {
128        let cases = [
129            (
130                [
131                    0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc,
132                    0xdd, 0xee, 0xff, 0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe, 0x01, 0x23,
133                    0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
134                ],
135                "00112233445566778899aabbccddeeff1032547698badcfe0123456789abcdef",
136            ),
137            (
138                [
139                    0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33,
140                    0x22, 0x11, 0x00, 0xef, 0xcd, 0xab, 0x89, 0x67, 0x45, 0x23, 0x01, 0xfe, 0xdc,
141                    0xba, 0x98, 0x76, 0x54, 0x32, 0x10,
142                ],
143                "ffeeddccbbaa99887766554433221100efcdab8967452301fedcba9876543210",
144            ),
145        ];
146
147        for (bytes, hex) in cases {
148            let hash = ShardlineHash::from_bytes(bytes);
149
150            assert_eq!(hash.hex_string(), hex);
151            let parsed = ShardlineHash::parse_hex(hex);
152            assert_eq!(parsed.unwrap(), hash);
153        }
154    }
155
156    #[test]
157    fn canonical_hash_rejects_uppercase_hex() {
158        let hash = ShardlineHash::from_bytes([31; 32]);
159        let invalid = hash.hex_string().replacen('f', "F", 1);
160        let result = ShardlineHash::parse_hex(&invalid);
161
162        assert!(matches!(result, Err(HashParseError::InvalidCharacter(_))));
163    }
164
165    #[test]
166    fn canonical_hash_rejects_short_hex() {
167        let result = ShardlineHash::parse_hex("abc");
168
169        assert!(matches!(result, Err(HashParseError::InvalidLength)));
170    }
171
172    #[test]
173    fn canonical_hash_rejects_long_hex() {
174        let result = ShardlineHash::parse_hex(
175            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
176        );
177
178        assert!(matches!(result, Err(HashParseError::InvalidLength)));
179    }
180
181    #[test]
182    fn canonical_hash_rejects_non_hex_character() {
183        let result = ShardlineHash::parse_hex(
184            "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
185        );
186
187        assert!(matches!(result, Err(HashParseError::InvalidCharacter(_))));
188    }
189
190    #[test]
191    fn raw_bytes_are_preserved() {
192        let bytes = [9; 32];
193        let hash = ShardlineHash::from_bytes(bytes);
194
195        assert_eq!(hash.as_bytes(), &bytes);
196    }
197
198    #[test]
199    fn hash_from_bytes_as_bytes_roundtrip() {
200        let cases = [
201            [0u8; 32],
202            [1u8; 32],
203            [0xffu8; 32],
204            [
205                0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab,
206                0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10, 0x00, 0x11, 0x22, 0x33,
207                0x44, 0x55, 0x66, 0x77,
208            ],
209        ];
210        for bytes in cases {
211            let hash = ShardlineHash::from_bytes(bytes);
212            assert_eq!(
213                hash.as_bytes(),
214                &bytes,
215                "from_bytes/as_bytes roundtrip failed for {bytes:?}"
216            );
217        }
218    }
219
220    #[test]
221    fn hash_parse_error_display_invalid_length() {
222        let error = HashParseError::InvalidLength;
223        let msg = error.to_string();
224        assert!(!msg.is_empty());
225        assert!(
226            msg.contains("64"),
227            "expected mention of 64 in display, got: {msg}"
228        );
229        assert!(
230            msg.contains("lowercase"),
231            "expected mention of lowercase in display, got: {msg}"
232        );
233    }
234
235    #[test]
236    fn hash_parse_error_display_invalid_character() {
237        let error = HashParseError::InvalidCharacter("bad char".to_owned());
238        let msg = error.to_string();
239        assert!(!msg.is_empty());
240        assert!(
241            msg.contains("bad char"),
242            "expected 'bad char' in display, got: {msg}"
243        );
244    }
245
246    #[test]
247    fn hash_copy_and_clone_work() {
248        let hash = ShardlineHash::from_bytes([42; 32]);
249        let cloned = hash;
250        assert_eq!(hash, cloned);
251        let copied = hash;
252        assert_eq!(hash, copied);
253    }
254
255    #[test]
256    fn hash_partial_eq_compares_bytes() {
257        let a = ShardlineHash::from_bytes([1; 32]);
258        let b = ShardlineHash::from_bytes([1; 32]);
259        let c = ShardlineHash::from_bytes([2; 32]);
260        assert_eq!(a, b);
261        assert_ne!(a, c);
262    }
263
264    #[test]
265    fn hash_parse_hex_rejects_garbage_after_valid_prefix() {
266        let result = ShardlineHash::parse_hex(&format!("{}zz", "a".repeat(64)));
267        assert!(matches!(result, Err(HashParseError::InvalidLength)));
268    }
269
270    #[test]
271    fn hash_hex_string_all_byte_values() {
272        let mut bytes = [0u8; 32];
273        for (i, byte) in bytes.iter_mut().enumerate() {
274            *byte = i as u8;
275        }
276        let hash = ShardlineHash::from_bytes(bytes);
277        let hex = hash.hex_string();
278        let expected = (0..32u8)
279            .map(|b| format!("{b:02x}"))
280            .collect::<Vec<_>>()
281            .concat();
282        assert_eq!(hex, expected);
283    }
284
285    #[test]
286    fn hash_parse_hex_rejects_uppercase_at_start() {
287        // Exactly 64 chars, first char uppercase => InvalidCharacter
288        let mixed = format!("A{}", "a".repeat(63));
289        let result = ShardlineHash::parse_hex(&mixed);
290        assert!(matches!(result, Err(HashParseError::InvalidCharacter(_))));
291    }
292
293    #[test]
294    fn hash_parse_hex_rejects_uppercase_in_middle() {
295        // Exactly 64 chars, one char in middle uppercase => InvalidCharacter
296        let mut bytes = "a".repeat(32);
297        bytes.push('F');
298        bytes.push_str(&"a".repeat(31));
299        assert_eq!(bytes.len(), 64);
300        let result = ShardlineHash::parse_hex(&bytes);
301        assert!(matches!(result, Err(HashParseError::InvalidCharacter(_))));
302    }
303
304    #[test]
305    fn hash_error_derive_impls() {
306        // HashParseError implements Clone
307        let _a = HashParseError::InvalidLength;
308        let _b = HashParseError::InvalidCharacter("msg".to_owned());
309    }
310}