Skip to main content

nula_core/util/
hex.rs

1//! Hex encoding and decoding helpers.
2//!
3//! Thin wrappers over [`faster_hex`] that present a small, allocation-aware
4//! API and a single error type. Lowercase encoding is used everywhere: the
5//! Nostr wire format expects lowercase hex (NIP-01) and consistency makes
6//! pubkeys/event IDs trivially comparable.
7
8use std::fmt;
9
10use thiserror::Error;
11
12/// Error raised while encoding or decoding hex.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
14#[non_exhaustive]
15pub enum HexError {
16    /// The hex string contained a non-ASCII or non-hex character.
17    #[error("invalid hex character")]
18    InvalidChar,
19    /// The hex string length is invalid (odd, or doesn't fit the target buffer).
20    #[error("invalid hex length: {0}")]
21    InvalidLength(usize),
22    /// The decoded length did not match the caller-supplied buffer.
23    #[error("hex length mismatch: expected {expected} bytes, got {actual}")]
24    LengthMismatch {
25        /// Bytes expected by the caller.
26        expected: usize,
27        /// Bytes provided by the caller.
28        actual: usize,
29    },
30    /// Upstream `faster_hex` reported an internal capacity overflow. Kept
31    /// as a distinct variant so callers can tell it apart from the
32    /// (much more common) `InvalidLength` failure on user input.
33    #[error("hex decoder reported an internal overflow")]
34    Overflow,
35}
36
37impl From<faster_hex::Error> for HexError {
38    fn from(err: faster_hex::Error) -> Self {
39        match err {
40            faster_hex::Error::InvalidChar => Self::InvalidChar,
41            faster_hex::Error::InvalidLength(len) => Self::InvalidLength(len),
42            faster_hex::Error::Overflow => Self::Overflow,
43        }
44    }
45}
46
47/// Encode `bytes` as a lowercase hex [`String`].
48#[must_use]
49pub fn encode<T>(bytes: T) -> String
50where
51    T: AsRef<[u8]>,
52{
53    faster_hex::hex_string(bytes.as_ref())
54}
55
56/// Encode `bytes` into a caller-provided buffer.
57///
58/// `out` must be exactly `2 * bytes.len()` long.
59///
60/// # Errors
61///
62/// Returns [`HexError::LengthMismatch`] if `out` is sized incorrectly.
63pub fn encode_to_slice<T>(bytes: T, out: &mut [u8]) -> Result<(), HexError>
64where
65    T: AsRef<[u8]>,
66{
67    let bytes = bytes.as_ref();
68    let expected = bytes.len() * 2;
69    if out.len() != expected {
70        return Err(HexError::LengthMismatch {
71            expected,
72            actual: out.len(),
73        });
74    }
75    faster_hex::hex_encode(bytes, out).map_err(HexError::from)?;
76    Ok(())
77}
78
79/// Decode a hex string into an owned [`Vec<u8>`].
80///
81/// # Errors
82///
83/// Returns an error if the input contains non-hex characters or has odd
84/// length.
85pub fn decode<T>(input: T) -> Result<Vec<u8>, HexError>
86where
87    T: AsRef<[u8]>,
88{
89    let input = input.as_ref();
90    if input.len() % 2 != 0 {
91        return Err(HexError::InvalidLength(input.len()));
92    }
93
94    let mut out = vec![0_u8; input.len() / 2];
95    faster_hex::hex_decode(input, &mut out).map_err(HexError::from)?;
96    Ok(out)
97}
98
99/// Decode a hex string into the caller-provided buffer.
100///
101/// `input.len()` must equal `2 * out.len()`.
102///
103/// # Errors
104///
105/// Returns [`HexError::LengthMismatch`] if the lengths do not match, or
106/// [`HexError::InvalidChar`] / [`HexError::InvalidLength`] if `input` is
107/// malformed.
108pub fn decode_to_slice<T>(input: T, out: &mut [u8]) -> Result<(), HexError>
109where
110    T: AsRef<[u8]>,
111{
112    let input = input.as_ref();
113    let expected = out.len() * 2;
114    if input.len() != expected {
115        return Err(HexError::LengthMismatch {
116            expected,
117            actual: input.len(),
118        });
119    }
120
121    faster_hex::hex_decode(input, out).map_err(HexError::from)?;
122    Ok(())
123}
124
125/// Render `bytes` as lowercase hex into the provided [`fmt::Formatter`].
126///
127/// Used by `Display` / `LowerHex` impls of fixed-size types (event IDs,
128/// pubkeys, …) to avoid intermediate allocations.
129///
130/// # Errors
131///
132/// Propagates errors from the formatter.
133pub fn fmt_lower<T>(bytes: T, f: &mut fmt::Formatter<'_>) -> fmt::Result
134where
135    T: AsRef<[u8]>,
136{
137    for byte in bytes.as_ref() {
138        write!(f, "{byte:02x}")?;
139    }
140    Ok(())
141}
142
143#[cfg(test)]
144mod tests {
145    use hex_literal::hex;
146
147    use super::*;
148
149    #[test]
150    fn encode_roundtrip() {
151        let bytes = hex!("deadbeef");
152        let encoded = encode(bytes);
153        assert_eq!(encoded, "deadbeef");
154        assert_eq!(decode(&encoded).unwrap(), bytes);
155    }
156
157    #[test]
158    fn encode_to_slice_exact() {
159        let bytes = hex!("00112233");
160        let mut buf = [0_u8; 8];
161        encode_to_slice(bytes, &mut buf).unwrap();
162        assert_eq!(&buf, b"00112233");
163    }
164
165    #[test]
166    fn encode_to_slice_wrong_length() {
167        let bytes = hex!("ab");
168        let mut buf = [0_u8; 4];
169        let err = encode_to_slice(bytes, &mut buf).unwrap_err();
170        assert!(matches!(
171            err,
172            HexError::LengthMismatch {
173                expected: 2,
174                actual: 4
175            }
176        ));
177    }
178
179    #[test]
180    fn decode_to_slice_exact() {
181        let mut buf = [0_u8; 4];
182        decode_to_slice("deadbeef", &mut buf).unwrap();
183        assert_eq!(buf, hex!("deadbeef"));
184    }
185
186    #[test]
187    fn decode_odd_length() {
188        let err = decode("abc").unwrap_err();
189        assert!(matches!(err, HexError::InvalidLength(3)));
190    }
191
192    #[test]
193    fn decode_invalid_char() {
194        let err = decode("zz").unwrap_err();
195        assert_eq!(err, HexError::InvalidChar);
196    }
197
198    #[test]
199    fn decode_to_slice_length_mismatch() {
200        let mut buf = [0_u8; 2];
201        let err = decode_to_slice("aabbcc", &mut buf).unwrap_err();
202        assert!(matches!(
203            err,
204            HexError::LengthMismatch {
205                expected: 4,
206                actual: 6
207            }
208        ));
209    }
210
211    #[test]
212    fn fmt_lower_matches_encode() {
213        struct Wrap([u8; 4]);
214        impl fmt::Display for Wrap {
215            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216                fmt_lower(self.0, f)
217            }
218        }
219        assert_eq!(Wrap(hex!("0a1b2c3d")).to_string(), "0a1b2c3d");
220    }
221}