Skip to main content

haz_cache/
hex.rs

1//! Lowercase hexadecimal encoding for 32-byte digests.
2//!
3//! Used internally by the manifest format (`CACHE-011`) and the
4//! sharded entry layout (`CACHE-010`). Output is always 64 ASCII
5//! characters in `[0-9a-f]`.
6
7use snafu::{Snafu, ensure};
8
9/// Failure modes for [`decode_32`].
10#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
11pub enum HexError {
12    /// Input was not exactly 64 ASCII characters long.
13    #[snafu(display("hex digest must be 64 characters; got {length}"))]
14    InvalidLength {
15        /// Byte length of the rejected input.
16        length: usize,
17    },
18
19    /// Input contained a non-hex byte.
20    #[snafu(display("hex digest contains invalid character {byte:#04x} at offset {offset}"))]
21    InvalidByte {
22        /// Offset of the offending byte (0-based).
23        offset: usize,
24        /// The offending byte.
25        byte: u8,
26    },
27}
28
29/// Encode a 32-byte digest as 64 lowercase hex characters.
30#[must_use]
31pub fn encode_32(bytes: &[u8; 32]) -> String {
32    let mut out = String::with_capacity(64);
33    for b in bytes {
34        out.push(nibble(b >> 4));
35        out.push(nibble(b & 0x0F));
36    }
37    out
38}
39
40/// Decode 64 lowercase or uppercase hex characters into a 32-byte
41/// digest.
42///
43/// # Errors
44///
45/// Returns [`HexError::InvalidLength`] when the input is not 64
46/// ASCII bytes long, [`HexError::InvalidByte`] when any character
47/// is outside `[0-9A-Fa-f]`.
48pub fn decode_32(s: &str) -> Result<[u8; 32], HexError> {
49    let bytes = s.as_bytes();
50    ensure!(
51        bytes.len() == 64,
52        InvalidLengthSnafu {
53            length: bytes.len()
54        }
55    );
56    let mut out = [0u8; 32];
57    for (i, chunk) in bytes.chunks_exact(2).enumerate() {
58        let hi = decode_nibble(chunk[0], i * 2)?;
59        let lo = decode_nibble(chunk[1], i * 2 + 1)?;
60        out[i] = (hi << 4) | lo;
61    }
62    Ok(out)
63}
64
65const fn nibble(n: u8) -> char {
66    match n {
67        0 => '0',
68        1 => '1',
69        2 => '2',
70        3 => '3',
71        4 => '4',
72        5 => '5',
73        6 => '6',
74        7 => '7',
75        8 => '8',
76        9 => '9',
77        10 => 'a',
78        11 => 'b',
79        12 => 'c',
80        13 => 'd',
81        14 => 'e',
82        _ => 'f',
83    }
84}
85
86fn decode_nibble(byte: u8, offset: usize) -> Result<u8, HexError> {
87    match byte {
88        b'0'..=b'9' => Ok(byte - b'0'),
89        b'a'..=b'f' => Ok(byte - b'a' + 10),
90        b'A'..=b'F' => Ok(byte - b'A' + 10),
91        _ => Err(HexError::InvalidByte { offset, byte }),
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use crate::hex::{HexError, decode_32, encode_32};
98
99    #[test]
100    fn encodes_all_zeros() {
101        let bytes = [0u8; 32];
102        let hex = encode_32(&bytes);
103        assert_eq!(hex.len(), 64);
104        assert!(hex.chars().all(|c| c == '0'));
105    }
106
107    #[test]
108    fn encodes_all_ff() {
109        let bytes = [0xFFu8; 32];
110        let hex = encode_32(&bytes);
111        assert_eq!(hex.len(), 64);
112        assert!(hex.chars().all(|c| c == 'f'));
113    }
114
115    #[test]
116    fn round_trip_random_pattern() {
117        let mut bytes = [0u8; 32];
118        for (i, b) in bytes.iter_mut().enumerate() {
119            // Arbitrary, deterministic pattern: each byte distinct.
120            *b = u8::try_from((i * 7) & 0xFF).unwrap();
121        }
122        let hex = encode_32(&bytes);
123        let back = decode_32(&hex).unwrap();
124        assert_eq!(back, bytes);
125    }
126
127    #[test]
128    fn output_is_lowercase_only() {
129        let bytes = [0xAB; 32];
130        let hex = encode_32(&bytes);
131        assert!(hex.chars().all(|c| !c.is_ascii_uppercase()));
132        assert_eq!(&hex[..2], "ab");
133    }
134
135    #[test]
136    fn decode_accepts_uppercase() {
137        let lower = "ab".repeat(32);
138        let upper = "AB".repeat(32);
139        assert_eq!(decode_32(&lower).unwrap(), decode_32(&upper).unwrap());
140    }
141
142    #[test]
143    fn decode_rejects_short_input() {
144        let err = decode_32("ab").unwrap_err();
145        assert!(matches!(err, HexError::InvalidLength { length: 2 }));
146    }
147
148    #[test]
149    fn decode_rejects_long_input() {
150        let s = "a".repeat(100);
151        let err = decode_32(&s).unwrap_err();
152        assert!(matches!(err, HexError::InvalidLength { length: 100 }));
153    }
154
155    #[test]
156    fn decode_rejects_non_hex_character() {
157        // 63 valid + 1 invalid (in the middle).
158        let mut s = "a".repeat(64);
159        s.replace_range(30..31, "z");
160        let err = decode_32(&s).unwrap_err();
161        match err {
162            HexError::InvalidByte { offset, byte } => {
163                assert_eq!(offset, 30);
164                assert_eq!(byte, b'z');
165            }
166            HexError::InvalidLength { .. } => {
167                panic!("expected InvalidByte, got {err:?}")
168            }
169        }
170    }
171
172    #[test]
173    fn encode_matches_known_vector() {
174        // First 4 bytes of BLAKE3("") = 0xaf, 0x13, 0x49, 0xb9.
175        let mut bytes = [0u8; 32];
176        bytes[0] = 0xaf;
177        bytes[1] = 0x13;
178        bytes[2] = 0x49;
179        bytes[3] = 0xb9;
180        let hex = encode_32(&bytes);
181        assert_eq!(&hex[..8], "af1349b9");
182    }
183}