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 std::fmt::Display;
19
20const 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
32const 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/// Never panics in practice: the output buffer is built from ASCII hex pairs in
53/// `ENCODE_PAIR`, 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 mut buf = Vec::with_capacity(bytes.len() * 2);
58    for &b in bytes {
59        buf.extend_from_slice(&ENCODE_PAIR[b as usize]);
60    }
61    String::from_utf8(buf).unwrap()
62}
63
64/// Encodes a byte slice as a `"0x"`-prefixed lowercase hexadecimal string.
65///
66/// # Panics
67///
68/// Never panics in practice: the output buffer is built from ASCII (`"0x"` plus
69/// `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 mut buf = Vec::with_capacity(2 + bytes.len() * 2);
74    buf.extend_from_slice(b"0x");
75    for &b in bytes {
76        buf.extend_from_slice(&ENCODE_PAIR[b as usize]);
77    }
78    String::from_utf8(buf).unwrap()
79}
80
81/// Decodes a hexadecimal string into bytes.
82///
83/// # Errors
84///
85/// Returns [`DecodeError`] 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    let mut out = Vec::with_capacity(hex.len() / 2);
92    for pair in hex.chunks_exact(2) {
93        let hi = DECODE_NIBBLE[pair[0] as usize];
94        let lo = DECODE_NIBBLE[pair[1] as usize];
95        if (hi | lo) & 0xF0 != 0 {
96            return Err(if hi == 0xFF {
97                DecodeError::InvalidChar(pair[0])
98            } else {
99                DecodeError::InvalidChar(pair[1])
100            });
101        }
102        out.push((hi << 4) | lo);
103    }
104    Ok(out)
105}
106
107/// Decodes a hexadecimal string into a fixed-size byte array.
108///
109/// # Errors
110///
111/// Returns [`DecodeError`] if the input length is not exactly `2 * N` or contains
112/// non-hex characters.
113pub fn decode_array<const N: usize>(data: impl AsRef<[u8]>) -> Result<[u8; N], DecodeError> {
114    let hex = data.as_ref();
115    if hex.len() != N * 2 {
116        return Err(DecodeError::LengthMismatch {
117            expected: N * 2,
118            actual: hex.len(),
119        });
120    }
121    let mut out = [0u8; N];
122
123    for (i, pair) in hex.chunks_exact(2).enumerate() {
124        let hi = DECODE_NIBBLE[pair[0] as usize];
125        let lo = DECODE_NIBBLE[pair[1] as usize];
126        if (hi | lo) & 0xF0 != 0 {
127            return Err(if hi == 0xFF {
128                DecodeError::InvalidChar(pair[0])
129            } else {
130                DecodeError::InvalidChar(pair[1])
131            });
132        }
133        out[i] = (hi << 4) | lo;
134    }
135    Ok(out)
136}
137
138/// Errors from hex decoding.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum DecodeError {
141    /// Input has an odd number of characters.
142    OddLength,
143    /// Input contains a non-hex byte.
144    InvalidChar(u8),
145    /// Input length does not match expected size.
146    LengthMismatch {
147        /// Expected hex string length.
148        expected: usize,
149        /// Actual hex string length.
150        actual: usize,
151    },
152}
153
154impl Display for DecodeError {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        match self {
157            Self::OddLength => f.write_str("odd number of hex characters"),
158            Self::InvalidChar(b) => write!(f, "invalid hex character: {b:#04x}"),
159            Self::LengthMismatch { expected, actual } => {
160                write!(f, "expected {expected} hex characters, was {actual}")
161            }
162        }
163    }
164}
165
166impl std::error::Error for DecodeError {}
167
168#[cfg(test)]
169mod tests {
170    use rstest::rstest;
171
172    use super::*;
173
174    #[rstest]
175    #[case(b"", "")]
176    #[case(b"\x00", "00")]
177    #[case(b"\xff", "ff")]
178    #[case(b"\xde\xad\xbe\xef", "deadbeef")]
179    #[case(b"hello", "68656c6c6f")]
180    fn test_encode(#[case] input: &[u8], #[case] expected: &str) {
181        assert_eq!(encode(input), expected);
182    }
183
184    #[rstest]
185    #[case("", b"")]
186    #[case("00", b"\x00")]
187    #[case("ff", b"\xff")]
188    #[case("FF", b"\xff")]
189    #[case("deadBEEF", b"\xde\xad\xbe\xef")]
190    #[case("68656c6c6f", b"hello")]
191    fn test_decode(#[case] input: &str, #[case] expected: &[u8]) {
192        assert_eq!(decode(input).unwrap(), expected);
193    }
194
195    #[rstest]
196    fn test_decode_odd_length() {
197        assert_eq!(decode("abc"), Err(DecodeError::OddLength));
198    }
199
200    #[rstest]
201    #[case("zz", DecodeError::InvalidChar(b'z'))]
202    #[case("z0", DecodeError::InvalidChar(b'z'))]
203    #[case("0z", DecodeError::InvalidChar(b'z'))]
204    fn test_decode_invalid_char(#[case] input: &str, #[case] expected: DecodeError) {
205        assert_eq!(decode(input), Err(expected));
206    }
207
208    #[rstest]
209    #[case(b"", "0x")]
210    #[case(b"\xde\xad", "0xdead")]
211    #[case(b"hello", "0x68656c6c6f")]
212    fn test_encode_prefixed(#[case] input: &[u8], #[case] expected: &str) {
213        assert_eq!(encode_prefixed(input), expected);
214    }
215
216    #[rstest]
217    fn test_decode_array() {
218        let result: [u8; 4] = decode_array("deadbeef").unwrap();
219        assert_eq!(result, [0xde, 0xad, 0xbe, 0xef]);
220    }
221
222    #[rstest]
223    fn test_decode_array_invalid_char() {
224        assert_eq!(
225            decode_array::<2>("xxff"),
226            Err(DecodeError::InvalidChar(b'x'))
227        );
228    }
229
230    #[rstest]
231    fn test_decode_array_length_mismatch() {
232        let result = decode_array::<4>("aabb");
233        assert_eq!(
234            result,
235            Err(DecodeError::LengthMismatch {
236                expected: 8,
237                actual: 4
238            })
239        );
240    }
241
242    #[rstest]
243    #[case(DecodeError::OddLength, "odd number of hex characters")]
244    #[case(DecodeError::InvalidChar(b'z'), "invalid hex character: 0x7a")]
245    #[case(
246        DecodeError::LengthMismatch { expected: 8, actual: 4 },
247        "expected 8 hex characters, was 4"
248    )]
249    fn test_decode_error_display(#[case] error: DecodeError, #[case] expected: &str) {
250        assert_eq!(error.to_string(), expected);
251    }
252
253    #[rstest]
254    fn test_roundtrip() {
255        let data = b"The quick brown fox jumps over the lazy dog";
256        assert_eq!(decode(encode(data)).unwrap(), data);
257    }
258}