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 "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 #[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#[derive(Debug, Clone, Error)]
96pub enum HashParseError {
97 #[error("hash must contain exactly 64 lowercase hexadecimal characters")]
99 InvalidLength,
100 #[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 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 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 let _a = HashParseError::InvalidLength;
308 let _b = HashParseError::InvalidCharacter("msg".to_owned());
309 }
310}