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        }
43
44        let decoded = hex::decode(value).map_err(|_error| HashParseError::InvalidCharacter)?;
45        let bytes = <[u8; HASH_BYTE_LENGTH]>::try_from(decoded)
46            .map_err(|_error| HashParseError::InvalidLength)?;
47
48        Ok(Self { bytes })
49    }
50
51    /// Returns canonical lowercase hexadecimal text.
52    #[must_use]
53    pub fn hex_string(&self) -> String {
54        let mut encoded = Vec::with_capacity(HASH_HEX_LENGTH);
55        for byte in self.bytes {
56            append_lower_hex_byte(&mut encoded, byte);
57        }
58
59        String::from_utf8(encoded).unwrap_or_default()
60    }
61}
62
63fn append_lower_hex_byte(output: &mut Vec<u8>, byte: u8) {
64    output.push(lower_hex_digit(byte >> 4));
65    output.push(lower_hex_digit(byte & 0x0f));
66}
67
68const fn lower_hex_digit(nibble: u8) -> u8 {
69    match nibble {
70        0 => b'0',
71        1 => b'1',
72        2 => b'2',
73        3 => b'3',
74        4 => b'4',
75        5 => b'5',
76        6 => b'6',
77        7 => b'7',
78        8 => b'8',
79        9 => b'9',
80        10 => b'a',
81        11 => b'b',
82        12 => b'c',
83        13 => b'd',
84        14 => b'e',
85        _ => b'f',
86    }
87}
88
89/// Hash parsing failure.
90#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
91pub enum HashParseError {
92    /// The hash string did not contain exactly 64 hexadecimal characters.
93    #[error("hash must contain exactly 64 lowercase hexadecimal characters")]
94    InvalidLength,
95    /// The hash string contained a character outside lowercase hexadecimal.
96    #[error("hash must use lowercase hexadecimal characters only")]
97    InvalidCharacter,
98}
99
100#[cfg(test)]
101mod tests {
102    use super::{HashParseError, ShardlineHash};
103
104    #[test]
105    fn canonical_hash_hex_round_trips_raw_bytes() {
106        let hash = ShardlineHash::from_bytes([
107            0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23,
108            24, 25, 26, 27, 28, 29, 30, 31,
109        ]);
110
111        let hex = hash.hex_string();
112
113        assert_eq!(
114            hex,
115            "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
116        );
117        assert_eq!(ShardlineHash::parse_hex(&hex), Ok(hash));
118    }
119
120    #[test]
121    fn canonical_hash_vectors_round_trip() {
122        let cases = [
123            (
124                [
125                    0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc,
126                    0xdd, 0xee, 0xff, 0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe, 0x01, 0x23,
127                    0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
128                ],
129                "00112233445566778899aabbccddeeff1032547698badcfe0123456789abcdef",
130            ),
131            (
132                [
133                    0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33,
134                    0x22, 0x11, 0x00, 0xef, 0xcd, 0xab, 0x89, 0x67, 0x45, 0x23, 0x01, 0xfe, 0xdc,
135                    0xba, 0x98, 0x76, 0x54, 0x32, 0x10,
136                ],
137                "ffeeddccbbaa99887766554433221100efcdab8967452301fedcba9876543210",
138            ),
139        ];
140
141        for (bytes, hex) in cases {
142            let hash = ShardlineHash::from_bytes(bytes);
143
144            assert_eq!(hash.hex_string(), hex);
145            assert_eq!(ShardlineHash::parse_hex(hex), Ok(hash));
146        }
147    }
148
149    #[test]
150    fn canonical_hash_rejects_uppercase_hex() {
151        let hash = ShardlineHash::from_bytes([31; 32]);
152        let invalid = hash.hex_string().replacen('f', "F", 1);
153        let result = ShardlineHash::parse_hex(&invalid);
154
155        assert_eq!(result, Err(HashParseError::InvalidCharacter));
156    }
157
158    #[test]
159    fn canonical_hash_rejects_short_hex() {
160        let result = ShardlineHash::parse_hex("abc");
161
162        assert_eq!(result, Err(HashParseError::InvalidLength));
163    }
164
165    #[test]
166    fn canonical_hash_rejects_long_hex() {
167        let result = ShardlineHash::parse_hex(
168            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
169        );
170
171        assert_eq!(result, Err(HashParseError::InvalidLength));
172    }
173
174    #[test]
175    fn canonical_hash_rejects_non_hex_character() {
176        let result = ShardlineHash::parse_hex(
177            "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
178        );
179
180        assert_eq!(result, Err(HashParseError::InvalidCharacter));
181    }
182
183    #[test]
184    fn raw_bytes_are_preserved() {
185        let bytes = [9; 32];
186        let hash = ShardlineHash::from_bytes(bytes);
187
188        assert_eq!(hash.as_bytes(), &bytes);
189    }
190}