Skip to main content

nautilus_core/
hex.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Hexadecimal encoding and decoding for byte slices.
17
18use thiserror::Error;
19
20pub(crate) const ENCODE_PAIR: [[u8; 2]; 256] = {
21    const NIBBLE: [u8; 16] = *b"0123456789abcdef";
22    let mut table = [[0u8; 2]; 256];
23    let mut i = 0u16;
24    while i < 256 {
25        table[i as usize] = [NIBBLE[(i >> 4) as usize], NIBBLE[(i & 0x0f) as usize]];
26        i += 1;
27    }
28    table
29};
30
31// 0xFF sentinel marks invalid hex characters
32pub(crate) const DECODE_NIBBLE: [u8; 256] = {
33    let mut table = [0xFFu8; 256];
34    let mut i = 0u8;
35    while i < 10 {
36        table[(b'0' + i) as usize] = i;
37        i += 1;
38    }
39    i = 0;
40    while i < 6 {
41        table[(b'a' + i) as usize] = 10 + i;
42        table[(b'A' + i) as usize] = 10 + i;
43        i += 1;
44    }
45    table
46};
47
48/// Encodes a byte slice as a lowercase hexadecimal string.
49///
50/// # Panics
51///
52/// Panics if the output capacity exceeds `isize::MAX` bytes.
53/// The output buffer is built from ASCII `ENCODE_PAIR` entries, so [`String::from_utf8`] always succeeds.
54#[must_use]
55pub fn encode(data: impl AsRef<[u8]>) -> String {
56    let bytes = data.as_ref();
57    let buf = bytes
58        .iter()
59        .flat_map(|&byte| ENCODE_PAIR[byte as usize])
60        .collect();
61    String::from_utf8(buf).expect("hex pairs are ASCII")
62}
63
64/// Encodes a byte slice as a `"0x"`-prefixed lowercase hexadecimal string.
65///
66/// # Panics
67///
68/// Panics if the output capacity exceeds `isize::MAX` bytes.
69/// The output buffer is built from ASCII (`"0x"` plus `ENCODE_PAIR` entries), so [`String::from_utf8`] always succeeds.
70#[must_use]
71pub fn encode_prefixed(data: impl AsRef<[u8]>) -> String {
72    let bytes = data.as_ref();
73    let buf = b"0x"
74        .iter()
75        .copied()
76        .chain(bytes.iter().flat_map(|&byte| ENCODE_PAIR[byte as usize]))
77        .collect();
78    String::from_utf8(buf).expect("hex pairs are ASCII")
79}
80
81/// Decodes a hexadecimal string into bytes.
82///
83/// # Errors
84///
85/// Returns an error if the input length is odd or contains non-hex characters.
86pub fn decode(data: impl AsRef<[u8]>) -> Result<Vec<u8>, DecodeError> {
87    let hex = data.as_ref();
88    if hex.len() % 2 != 0 {
89        return Err(DecodeError::OddLength);
90    }
91
92    if hex.is_empty() {
93        return Ok(Vec::new());
94    }
95
96    if hex.len() < 32 {
97        let mut out = Vec::with_capacity(hex.len() / 2);
98        decode_pairs_into(hex, &mut out)?;
99        return Ok(out);
100    }
101    decode_packed(hex)
102}
103
104fn decode_packed(hex: &[u8]) -> Result<Vec<u8>, DecodeError> {
105    // Reject errors in the first chunk before allocating an output buffer.
106    let first = decode_4_bytes(
107        hex[..8]
108            .try_into()
109            .expect("packed hex has at least eight characters"),
110    )?;
111    let mut out = vec![0; hex.len() / 2];
112    out[..4].copy_from_slice(&first);
113    decode_into(&hex[8..], &mut out[4..])?;
114    Ok(out)
115}
116
117fn decode_pairs_into(hex: &[u8], out: &mut Vec<u8>) -> Result<(), DecodeError> {
118    for pair in hex.as_chunks::<2>().0 {
119        let hi = DECODE_NIBBLE[pair[0] as usize];
120        let lo = DECODE_NIBBLE[pair[1] as usize];
121        if (hi | lo) & 0xF0 != 0 {
122            return Err(if hi == 0xFF {
123                DecodeError::InvalidChar(pair[0])
124            } else {
125                DecodeError::InvalidChar(pair[1])
126            });
127        }
128        out.push((hi << 4) | lo);
129    }
130    Ok(())
131}
132
133/// Decodes a hexadecimal string into a fixed-size byte array.
134///
135/// # Errors
136///
137/// Returns an error if the input length is not exactly `2 * N` or contains non-hex characters.
138pub fn decode_array<const N: usize>(data: impl AsRef<[u8]>) -> Result<[u8; N], DecodeError> {
139    let hex = data.as_ref();
140    if hex.len() != N * 2 {
141        return Err(DecodeError::LengthMismatch {
142            expected: N * 2,
143            actual: hex.len(),
144        });
145    }
146    let mut out = [0u8; N];
147    decode_into(hex, &mut out)?;
148    Ok(out)
149}
150
151// Callers establish exactly two input characters per output byte before entering this kernel
152#[inline]
153fn decode_into(hex: &[u8], out: &mut [u8]) -> Result<(), DecodeError> {
154    debug_assert_eq!(
155        hex.len(),
156        out.len() * 2,
157        "Invariant: hex has two characters per output byte"
158    );
159    let (chunks, tail) = hex.as_chunks::<8>();
160    let (out_chunks, out_tail) = out.as_chunks_mut::<4>();
161    for (decoded, chunk) in out_chunks.iter_mut().zip(chunks) {
162        *decoded = decode_4_bytes(*chunk)?;
163    }
164
165    for (decoded, pair) in out_tail.iter_mut().zip(tail.as_chunks::<2>().0) {
166        let hi = DECODE_NIBBLE[pair[0] as usize];
167        let lo = DECODE_NIBBLE[pair[1] as usize];
168        if (hi | lo) & 0xF0 != 0 {
169            return Err(if hi == 0xFF {
170                DecodeError::InvalidChar(pair[0])
171            } else {
172                DecodeError::InvalidChar(pair[1])
173            });
174        }
175        *decoded = (hi << 4) | lo;
176    }
177    Ok(())
178}
179
180#[inline(always)]
181fn decode_4_bytes(input: [u8; 8]) -> Result<[u8; 4], DecodeError> {
182    const HIGH_BITS: u64 = 0x8080_8080_8080_8080;
183    let word = u64::from_le_bytes(input);
184    let ascii = word & !HIGH_BITS;
185    let lowercase = ascii | 0x2020_2020_2020_2020;
186    // These subtractions cannot borrow between lanes; bit 7 tests the inclusive ASCII ranges.
187    let digits_low = (ascii | HIGH_BITS) - 0x3030_3030_3030_3030;
188    let digits_high = 0xb9b9_b9b9_b9b9_b9b9 - ascii;
189    let letters_low = (lowercase | HIGH_BITS) - 0x6161_6161_6161_6161;
190    let letters_high = 0xe6e6_e6e6_e6e6_e6e6 - lowercase;
191    let valid = ((digits_low & digits_high) | (letters_low & letters_high)) & HIGH_BITS;
192    let invalid = (!valid | word) & HIGH_BITS;
193    if invalid != 0 {
194        // Little-endian lanes put the first invalid input byte at the lowest set bit.
195        let index = (invalid.trailing_zeros() / 8) as usize;
196        return Err(DecodeError::InvalidChar(input[index]));
197    }
198
199    // For validated ASCII hex, bit 6 distinguishes letters; each nibble fits its byte lane.
200    let nibbles = (word & 0x0f0f_0f0f_0f0f_0f0f) + ((word >> 6) & 0x0303_0303_0303_0303) * 9;
201    let pairs = ((nibbles & 0x000f_000f_000f_000f) << 4) | ((nibbles >> 8) & 0x000f_000f_000f_000f);
202    let bytes = pairs.to_le_bytes();
203    Ok([bytes[0], bytes[2], bytes[4], bytes[6]])
204}
205
206/// Errors from hex decoding.
207#[derive(Debug, Clone, PartialEq, Eq, Error)]
208pub enum DecodeError {
209    /// Input has an odd number of characters.
210    #[error("odd number of hex characters")]
211    OddLength,
212    /// Input contains a non-hex byte.
213    #[error("invalid hex character: {0:#04x}")]
214    InvalidChar(u8),
215    /// Input length does not match expected size.
216    #[error("expected {expected} hex characters, was {actual}")]
217    LengthMismatch {
218        /// Expected hex string length.
219        expected: usize,
220        /// Actual hex string length.
221        actual: usize,
222    },
223}
224
225#[cfg(test)]
226mod tests {
227    use std::fmt::Write;
228
229    use proptest::prelude::*;
230    use rstest::rstest;
231
232    use super::*;
233    use crate::hex;
234
235    #[rstest]
236    #[case(b"", "")]
237    #[case(b"\x00", "00")]
238    #[case(b"\xff", "ff")]
239    #[case(b"\xde\xad\xbe\xef", "deadbeef")]
240    #[case(b"hello", "68656c6c6f")]
241    fn test_encode(#[case] input: &[u8], #[case] expected: &str) {
242        assert_eq!(encode(input), expected);
243    }
244
245    #[rstest]
246    #[case("", b"")]
247    #[case("00", b"\x00")]
248    #[case("ff", b"\xff")]
249    #[case("FF", b"\xff")]
250    #[case("deadBEEF", b"\xde\xad\xbe\xef")]
251    #[case("68656c6c6f", b"hello")]
252    fn test_decode(#[case] input: &str, #[case] expected: &[u8]) {
253        assert_eq!(decode(input).unwrap(), expected);
254    }
255
256    #[rstest]
257    fn test_decode_odd_length() {
258        assert_eq!(decode("abc"), Err(DecodeError::OddLength));
259    }
260
261    #[rstest]
262    #[case("zz", DecodeError::InvalidChar(b'z'))]
263    #[case("z0", DecodeError::InvalidChar(b'z'))]
264    #[case("0z", DecodeError::InvalidChar(b'z'))]
265    fn test_decode_invalid_char(#[case] input: &str, #[case] expected: DecodeError) {
266        assert_eq!(decode(input), Err(expected));
267    }
268
269    #[rstest]
270    #[case(b"", "0x")]
271    #[case(b"\xde\xad", "0xdead")]
272    #[case(b"hello", "0x68656c6c6f")]
273    fn test_encode_prefixed(#[case] input: &[u8], #[case] expected: &str) {
274        assert_eq!(encode_prefixed(input), expected);
275    }
276
277    #[rstest]
278    fn test_decode_array() {
279        let result: [u8; 4] = decode_array("deadbeef").unwrap();
280        assert_eq!(result, [0xde, 0xad, 0xbe, 0xef]);
281    }
282
283    #[rstest]
284    fn test_decode_array_invalid_char() {
285        assert_eq!(
286            decode_array::<2>("xxff"),
287            Err(DecodeError::InvalidChar(b'x'))
288        );
289    }
290
291    #[rstest]
292    fn test_decode_array_length_mismatch() {
293        let result = decode_array::<4>("aabb");
294        assert_eq!(
295            result,
296            Err(DecodeError::LengthMismatch {
297                expected: 8,
298                actual: 4
299            })
300        );
301    }
302
303    #[rstest]
304    #[case(DecodeError::OddLength, "odd number of hex characters")]
305    #[case(DecodeError::InvalidChar(b'z'), "invalid hex character: 0x7a")]
306    #[case(
307        DecodeError::LengthMismatch { expected: 8, actual: 4 },
308        "expected 8 hex characters, was 4"
309    )]
310    fn test_decode_error_display(#[case] error: DecodeError, #[case] expected: &str) {
311        assert_eq!(error.to_string(), expected);
312    }
313
314    #[rstest]
315    fn test_roundtrip() {
316        let data = b"The quick brown fox jumps over the lazy dog";
317        assert_eq!(decode(encode(data)).unwrap(), data);
318    }
319
320    fn decode_oracle(input: &[u8]) -> Result<Vec<u8>, DecodeError> {
321        if !input.len().is_multiple_of(2) {
322            return Err(DecodeError::OddLength);
323        }
324        input
325            .as_chunks::<2>()
326            .0
327            .iter()
328            .map(|pair| {
329                let hi = char::from(pair[0])
330                    .to_digit(16)
331                    .ok_or(DecodeError::InvalidChar(pair[0]))?;
332                let lo = char::from(pair[1])
333                    .to_digit(16)
334                    .ok_or(DecodeError::InvalidChar(pair[1]))?;
335                Ok(u8::try_from((hi << 4) | lo).unwrap())
336            })
337            .collect()
338    }
339
340    #[rstest]
341    fn test_decode_checks_every_byte_at_each_packed_position() {
342        for position in 0..8 {
343            for byte in 0..=u8::MAX {
344                let mut input = *b"01aBcDeF";
345                input[position] = byte;
346                let expected = decode_oracle(&input);
347                assert_eq!(hex::decode(input), expected);
348                assert_eq!(hex::decode_array::<4>(input).map(Vec::from), expected);
349            }
350        }
351    }
352
353    #[rstest]
354    fn test_decode_checks_every_byte_across_wide_chunks() {
355        for position in 0..48 {
356            for byte in 0..=u8::MAX {
357                let mut input = *b"01aBcDeF01aBcDeF01aBcDeF01aBcDeF01aBcDeF01aBcDeF";
358                input[position] = byte;
359                let expected = decode_oracle(&input);
360                assert_eq!(hex::decode(input), expected);
361                assert_eq!(hex::decode_array::<24>(input).map(Vec::from), expected);
362            }
363        }
364    }
365
366    #[rstest]
367    fn test_decode_reports_first_invalid_byte_across_wide_chunks_and_tail() {
368        for position in 0..50 {
369            let mut input = *b"01aBcDeF01aBcDeF01aBcDeF01aBcDeF01aBcDeF01aBcDeF01";
370            input[position] = 0xff;
371            if position + 1 < input.len() {
372                input[position + 1] = b'z';
373            }
374            assert_eq!(hex::decode(input), Err(DecodeError::InvalidChar(0xff)));
375            assert_eq!(
376                hex::decode_array::<25>(input),
377                Err(DecodeError::InvalidChar(0xff))
378            );
379        }
380    }
381
382    #[rstest]
383    fn test_decode_reports_first_invalid_byte_after_complete_chunks() {
384        for position in 0..24 {
385            let mut input = *b"01aBcDeF01aBcDeF01aBcDeF";
386            input[position] = 0xff;
387            if position + 1 < input.len() {
388                input[position + 1] = b'z';
389            }
390            assert_eq!(hex::decode(input), Err(DecodeError::InvalidChar(0xff)));
391            assert_eq!(
392                hex::decode_array::<12>(input),
393                Err(DecodeError::InvalidChar(0xff))
394            );
395        }
396    }
397
398    #[rstest]
399    fn test_decode_length_errors_precede_invalid_characters() {
400        assert_eq!(hex::decode([0xff; 9]), Err(DecodeError::OddLength));
401        assert_eq!(
402            hex::decode_array::<4>([0xff; 9]),
403            Err(DecodeError::LengthMismatch {
404                expected: 8,
405                actual: 9
406            })
407        );
408    }
409
410    fn assert_array_roundtrip<const N: usize>() {
411        let bytes = std::array::from_fn::<_, N, _>(|i| u8::try_from(i * 7).unwrap());
412        let text: String = format_hex_reference(&bytes).to_ascii_uppercase();
413        assert_eq!(hex::decode_array::<N>(&text), Ok(bytes));
414        assert_eq!(hex::decode(&text), Ok(bytes.to_vec()));
415    }
416
417    #[rstest]
418    fn test_decode_array_handles_empty_short_and_partial_chunks() {
419        assert_array_roundtrip::<0>();
420        assert_array_roundtrip::<1>();
421        assert_array_roundtrip::<2>();
422        assert_array_roundtrip::<3>();
423        assert_array_roundtrip::<4>();
424        assert_array_roundtrip::<5>();
425        assert_array_roundtrip::<6>();
426        assert_array_roundtrip::<7>();
427        assert_array_roundtrip::<8>();
428        assert_array_roundtrip::<9>();
429        assert_array_roundtrip::<32>();
430    }
431
432    proptest! {
433        #[rstest]
434        fn prop_decode_preserves_values_and_error_priority(input in prop::collection::vec(any::<u8>(), 0..256)) {
435            prop_assert_eq!(hex::decode(&input), decode_oracle(&input));
436        }
437
438        #[rstest]
439        fn prop_decode_matches_numeric_formatting(bytes in prop::collection::vec(any::<u8>(), 0..1024)) {
440            let text: String = format_hex_reference(&bytes).to_ascii_uppercase();
441            prop_assert_eq!(hex::decode(&text), Ok(bytes));
442        }
443    }
444
445    #[rstest]
446    fn test_hex_covers_every_byte() {
447        let bytes: Vec<u8> = (0..=u8::MAX).collect();
448        let expected: String = format_hex_reference(&bytes);
449        assert_eq!(hex::encode(&bytes), expected);
450        assert_eq!(hex::encode_prefixed(&bytes), format!("0x{expected}"));
451        assert_eq!(hex::decode(expected.to_uppercase()).unwrap(), bytes);
452    }
453
454    proptest! {
455        #[rstest]
456        fn prop_hex_matches_numeric_formatting(bytes in prop::collection::vec(any::<u8>(), 0..1024)) {
457            let expected: String = format_hex_reference(&bytes);
458            prop_assert_eq!(hex::encode(&bytes), expected);
459        }
460    }
461
462    fn format_hex_reference(bytes: &[u8]) -> String {
463        let mut text = String::with_capacity(bytes.len() * 2);
464
465        for byte in bytes {
466            write!(text, "{byte:02x}").unwrap();
467        }
468        text
469    }
470}