1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3
4const HASH_BYTE_LENGTH: usize = 32;
5const HASH_HEX_LENGTH: usize = 64;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9pub struct ShardlineHash {
10 bytes: [u8; HASH_BYTE_LENGTH],
11}
12
13impl ShardlineHash {
14 #[must_use]
16 pub const fn from_bytes(bytes: [u8; HASH_BYTE_LENGTH]) -> Self {
17 Self { bytes }
18 }
19
20 #[must_use]
22 pub const fn as_bytes(&self) -> &[u8; HASH_BYTE_LENGTH] {
23 &self.bytes
24 }
25
26 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 #[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#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
91pub enum HashParseError {
92 #[error("hash must contain exactly 64 lowercase hexadecimal characters")]
94 InvalidLength,
95 #[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}