1const HEX_CHARS_LOWER: &[u8; 16] = b"0123456789abcdef";
24
25const HEX_CHARS_UPPER: &[u8; 16] = b"0123456789ABCDEF";
27
28#[inline]
34fn hex_char(table: &[u8; 16], nibble: u8) -> char {
35 #[allow(
40 clippy::indexing_slicing,
41 reason = "nibble is always byte >> 4 or byte & 0x0f, which is 0..=15; table has 16 elements"
42 )]
43 #[allow(
44 clippy::as_conversions,
45 reason = "table bytes are ASCII digits/letters (0-127); casting u8 to char is always valid here"
46 )]
47 (table[usize::from(nibble)] as char)
48}
49
50#[must_use]
54pub fn encode(input: impl AsRef<[u8]>) -> String {
55 let input = input.as_ref();
56 let mut out = String::with_capacity(input.len().saturating_mul(2));
59 for &byte in input {
60 out.push(hex_char(HEX_CHARS_LOWER, byte >> 4));
61 out.push(hex_char(HEX_CHARS_LOWER, byte & 0x0f));
62 }
63 out
64}
65
66#[must_use]
68pub fn encode_upper(input: impl AsRef<[u8]>) -> String {
69 let input = input.as_ref();
70 let mut out = String::with_capacity(input.len().saturating_mul(2));
71 for &byte in input {
72 out.push(hex_char(HEX_CHARS_UPPER, byte >> 4));
73 out.push(hex_char(HEX_CHARS_UPPER, byte & 0x0f));
74 }
75 out
76}
77
78#[non_exhaustive]
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum DecodeError {
82 OddLength,
84 InvalidChar { index: usize, byte: u8 },
86}
87
88impl core::fmt::Display for DecodeError {
89 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
90 match self {
91 Self::OddLength => write!(f, "odd-length hex string"),
92 Self::InvalidChar { index, byte } => {
93 write!(f, "invalid hex char 0x{byte:02x} at index {index}")
94 }
95 }
96 }
97}
98
99impl std::error::Error for DecodeError {}
100
101pub fn decode(input: impl AsRef<[u8]>) -> Result<Vec<u8>, DecodeError> {
108 let input = input.as_ref();
109 if input.len() % 2 != 0 {
110 return Err(DecodeError::OddLength);
111 }
112 let mut out = Vec::with_capacity(input.len() / 2);
113 for pair in input.chunks_exact(2) {
114 #[allow(
118 clippy::indexing_slicing,
119 reason = "chunks_exact(2) guarantees pair.len() == 2; indices 0 and 1 are always valid"
120 )]
121 let high = hex_val(pair[0], 0)?;
122 #[allow(
123 clippy::indexing_slicing,
124 reason = "chunks_exact(2) guarantees pair.len() == 2; indices 0 and 1 are always valid"
125 )]
126 let low = hex_val(pair[1], 1)?;
127 #[allow(
130 clippy::arithmetic_side_effects,
131 reason = "high is 0..=15 (from hex_val), so high << 4 is 0..=240; OR with low (0..=15) gives 0..=255; no overflow"
132 )]
133 out.push((high << 4) | low);
134 }
135 Ok(out)
136}
137
138#[inline]
140const fn hex_val(byte: u8, offset: usize) -> Result<u8, DecodeError> {
141 match byte {
142 #[allow(
149 clippy::arithmetic_side_effects,
150 reason = "match arm guard proves byte >= b'0'; subtraction gives 0..=9 which fits in u8"
151 )]
152 b'0'..=b'9' => Ok(byte - b'0'),
153 #[allow(
154 clippy::arithmetic_side_effects,
155 reason = "match arm guard proves byte >= b'a' and byte - b'a' <= 5; adding 10 gives 10..=15, fitting in u8"
156 )]
157 b'a'..=b'f' => Ok(byte - b'a' + 10),
158 #[allow(
159 clippy::arithmetic_side_effects,
160 reason = "match arm guard proves byte >= b'A' and byte - b'A' <= 5; adding 10 gives 10..=15, fitting in u8"
161 )]
162 b'A'..=b'F' => Ok(byte - b'A' + 10),
163 _ => Err(DecodeError::InvalidChar {
164 index: offset,
165 byte,
166 }),
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn encode_empty() {
176 assert_eq!(encode(b""), "");
177 }
178
179 #[test]
180 fn encode_hello() {
181 assert_eq!(encode(b"Hello"), "48656c6c6f");
182 }
183
184 #[test]
185 fn encode_all_bytes() {
186 let input: Vec<u8> = (0..=255).collect();
187 let encoded = encode(&input);
188 assert_eq!(encoded.len(), 512);
189 assert!(encoded.starts_with("000102"));
190 assert!(encoded.ends_with("fdfeff"));
191 }
192
193 #[test]
194 fn encode_upper_hello() {
195 assert_eq!(encode_upper(b"Hello"), "48656C6C6F");
196 }
197
198 #[test]
199 fn decode_empty() {
200 assert_eq!(decode("").ok(), Some(vec![]));
201 }
202
203 #[test]
204 fn decode_hello() {
205 assert_eq!(decode("48656c6c6f").ok(), Some(b"Hello".to_vec()));
206 }
207
208 #[test]
209 fn decode_uppercase() {
210 assert_eq!(decode("48656C6C6F").ok(), Some(b"Hello".to_vec()));
211 }
212
213 #[test]
214 fn decode_mixed_case() {
215 assert_eq!(decode("48656C6c6F").ok(), Some(b"Hello".to_vec()));
216 }
217
218 #[test]
219 fn decode_odd_length() {
220 assert_eq!(decode("abc"), Err(DecodeError::OddLength));
221 }
222
223 #[test]
224 fn decode_invalid_char() {
225 let err = decode("zz");
226 assert!(matches!(err, Err(DecodeError::InvalidChar { .. })));
227 }
228
229 #[test]
230 fn roundtrip_all_bytes() {
231 let input: Vec<u8> = (0..=255).collect();
232 let encoded = encode(&input);
233 let decoded = decode(&encoded);
234 assert_eq!(decoded.ok(), Some(input));
235 }
236
237 #[test]
239 fn rfc4648_test_vectors() {
240 let vectors = [
241 ("", ""),
242 ("f", "66"),
243 ("fo", "666f"),
244 ("foo", "666f6f"),
245 ("foob", "666f6f62"),
246 ("fooba", "666f6f6261"),
247 ("foobar", "666f6f626172"),
248 ];
249 for (input, expected) in vectors {
250 assert_eq!(encode(input.as_bytes()), expected, "encode({input:?})");
251 assert_eq!(
252 decode(expected).ok(),
253 Some(input.as_bytes().to_vec()),
254 "decode({expected:?})"
255 );
256 }
257 }
258}