entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! Hexadecimal encoding and decoding.
//!
//! Encodes bytes as lowercase hex strings and decodes hex strings
//! case-insensitively back to bytes.

use core::fmt;

// ---------------------------------------------------------------------------
// Lookup tables
// ---------------------------------------------------------------------------

/// Lowercase hex digits for encoding.
const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";

/// Decode table: maps ASCII byte value to its 4-bit nibble value.
/// 255 marks invalid positions.
const HEX_DECODE: [u8; 256] = build_hex_decode_table();

const fn build_hex_decode_table() -> [u8; 256] {
    let mut table = [255u8; 256];
    let mut i: u8 = 0;

    // '0'..='9'
    while i <= 9 {
        table[(b'0' + i) as usize] = i;
        i += 1;
    }

    // 'a'..='f'
    i = 0;
    while i < 6 {
        table[(b'a' + i) as usize] = 10 + i;
        table[(b'A' + i) as usize] = 10 + i;
        i += 1;
    }

    table
}

// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HexDecodeErrorKind {
    /// Input has an odd number of characters.
    OddLength,
    /// Input contains a character that is not a valid hex digit.
    InvalidCharacter,
}

/// Error returned when hex decoding fails.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HexDecodeError {
    kind: HexDecodeErrorKind,
}

impl HexDecodeError {
    const fn new(kind: HexDecodeErrorKind) -> Self {
        Self { kind }
    }
}

impl fmt::Display for HexDecodeError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self.kind {
            HexDecodeErrorKind::OddLength => write!(f, "hex: odd-length input"),
            HexDecodeErrorKind::InvalidCharacter => write!(f, "hex: invalid character in input"),
        }
    }
}

impl std::error::Error for HexDecodeError {}

// ---------------------------------------------------------------------------
// Encoding
// ---------------------------------------------------------------------------

/// Encode bytes as a lowercase hexadecimal string.
///
/// # Panics
///
/// This function does not panic in practice — the output is always valid ASCII.
#[must_use]
pub fn hex_encode(input: &[u8]) -> String {
    let mut out = Vec::with_capacity(input.len() * 2);
    for &byte in input {
        out.push(HEX_DIGITS[(byte >> 4) as usize]);
        out.push(HEX_DIGITS[(byte & 0x0F) as usize]);
    }
    // NOTE: output consists entirely of ASCII hex digits, so from_utf8
    // cannot fail in practice. We avoid `unsafe` to maintain the
    // crate's deny(unsafe_code) policy.
    // SAFETY (logical): all bytes pushed to `out` come from HEX_DIGITS
    // which are ASCII; the unwrap is unreachable.
    #[allow(clippy::expect_used)]
    String::from_utf8(out).expect("hex output is always valid ASCII")
}

/// Uppercase hex digits for encoding.
const HEX_DIGITS_UPPER: &[u8; 16] = b"0123456789ABCDEF";

/// Encode bytes as an uppercase hexadecimal string.
///
/// Identical to [`hex_encode`] but uses uppercase `A`..`F`.
///
/// # Panics
///
/// This function does not panic in practice — the output is always valid ASCII.
#[must_use]
pub fn hex_encode_upper(input: &[u8]) -> String {
    let mut out = Vec::with_capacity(input.len() * 2);
    for &byte in input {
        out.push(HEX_DIGITS_UPPER[(byte >> 4) as usize]);
        out.push(HEX_DIGITS_UPPER[(byte & 0x0F) as usize]);
    }
    // NOTE: output consists entirely of ASCII hex digits, so from_utf8
    // cannot fail in practice. We avoid `unsafe` to maintain the
    // crate's deny(unsafe_code) policy.
    // SAFETY (logical): all bytes pushed to `out` come from HEX_DIGITS_UPPER
    // which are ASCII; the unwrap is unreachable.
    #[allow(clippy::expect_used)]
    String::from_utf8(out).expect("hex output is always valid ASCII")
}

// ---------------------------------------------------------------------------
// Decoding
// ---------------------------------------------------------------------------

/// Decode a hexadecimal string to bytes.
///
/// Accepts both uppercase and lowercase hex digits. The input must have an
/// even number of characters.
///
/// # Errors
///
/// Returns [`HexDecodeError`] if the input has an odd length or contains
/// non-hex characters.
pub fn hex_decode(input: &str) -> Result<Vec<u8>, HexDecodeError> {
    let bytes = input.as_bytes();

    if bytes.len() % 2 != 0 {
        return Err(HexDecodeError::new(HexDecodeErrorKind::OddLength));
    }

    let mut out = Vec::with_capacity(bytes.len() / 2);

    for pair in bytes.chunks_exact(2) {
        let high = decode_nibble(pair[0])?;
        let low = decode_nibble(pair[1])?;
        out.push((high << 4) | low);
    }

    Ok(out)
}

/// Resolve a single ASCII byte to its 4-bit hex nibble value.
#[inline]
fn decode_nibble(byte: u8) -> Result<u8, HexDecodeError> {
    let val = HEX_DECODE[byte as usize];
    if val == 255 {
        return Err(HexDecodeError::new(HexDecodeErrorKind::InvalidCharacter));
    }
    Ok(val)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    // --- Encoding ---

    #[test]
    fn encode_empty() {
        assert_eq!(hex_encode(b""), "");
    }

    #[test]
    fn encode_single_byte() {
        assert_eq!(hex_encode(&[0x00]), "00");
        assert_eq!(hex_encode(&[0xFF]), "ff");
        assert_eq!(hex_encode(&[0xAB]), "ab");
    }

    #[test]
    fn encode_multi_byte() {
        assert_eq!(hex_encode(&[0xDE, 0xAD, 0xBE, 0xEF]), "deadbeef");
    }

    #[test]
    fn encode_all_bytes() {
        let input: Vec<u8> = (0..=255).collect();
        let encoded = hex_encode(&input);
        assert_eq!(encoded.len(), 512);
        // Spot-check boundaries.
        assert!(encoded.starts_with("00"));
        assert!(encoded.ends_with("ff"));
    }

    #[test]
    fn encode_produces_lowercase() {
        let result = hex_encode(&[0xCA, 0xFE]);
        assert_eq!(result, "cafe");
        // Ensure no uppercase letters.
        assert_eq!(result, result.to_lowercase());
    }

    // --- Uppercase Encoding ---

    #[test]
    fn encode_upper_empty() {
        assert_eq!(hex_encode_upper(b""), "");
    }

    #[test]
    fn encode_upper_single_byte() {
        assert_eq!(hex_encode_upper(&[0x00]), "00");
        assert_eq!(hex_encode_upper(&[0xFF]), "FF");
        assert_eq!(hex_encode_upper(&[0xAB]), "AB");
    }

    #[test]
    fn encode_upper_multi_byte() {
        assert_eq!(hex_encode_upper(&[0xDE, 0xAD, 0xBE, 0xEF]), "DEADBEEF");
    }

    #[test]
    fn encode_upper_produces_uppercase() {
        let result = hex_encode_upper(&[0xca, 0xfe]);
        assert_eq!(result, "CAFE");
        assert_eq!(result, result.to_uppercase());
    }

    // --- Decoding ---

    #[test]
    fn decode_empty() {
        assert_eq!(hex_decode("").unwrap(), b"");
    }

    #[test]
    fn decode_single_byte() {
        assert_eq!(hex_decode("00").unwrap(), &[0x00]);
        assert_eq!(hex_decode("ff").unwrap(), &[0xFF]);
        assert_eq!(hex_decode("ab").unwrap(), &[0xAB]);
    }

    #[test]
    fn decode_multi_byte() {
        assert_eq!(hex_decode("deadbeef").unwrap(), &[0xDE, 0xAD, 0xBE, 0xEF]);
    }

    #[test]
    fn decode_case_insensitive() {
        assert_eq!(hex_decode("DEADBEEF").unwrap(), &[0xDE, 0xAD, 0xBE, 0xEF]);
        assert_eq!(hex_decode("DeAdBeEf").unwrap(), &[0xDE, 0xAD, 0xBE, 0xEF]);
    }

    // --- Round-trip ---

    #[test]
    fn round_trip_all_bytes() {
        let data: Vec<u8> = (0..=255).collect();
        let encoded = hex_encode(&data);
        let decoded = hex_decode(&encoded).unwrap();
        assert_eq!(decoded, data);
    }

    #[test]
    fn round_trip_short_lengths() {
        for len in 0..=32_u8 {
            let data: Vec<u8> = (0..len).collect();
            let encoded = hex_encode(&data);
            let decoded = hex_decode(&encoded).unwrap();
            assert_eq!(decoded, data, "round-trip failed for length {len}");
        }
    }

    // --- Error cases ---

    #[test]
    fn decode_rejects_odd_length() {
        let err = hex_decode("abc").unwrap_err();
        assert_eq!(err, HexDecodeError::new(HexDecodeErrorKind::OddLength));
    }

    #[test]
    fn decode_rejects_single_char() {
        let err = hex_decode("a").unwrap_err();
        assert_eq!(err, HexDecodeError::new(HexDecodeErrorKind::OddLength));
    }

    #[test]
    fn decode_rejects_invalid_character() {
        let err = hex_decode("zz").unwrap_err();
        assert_eq!(
            err,
            HexDecodeError::new(HexDecodeErrorKind::InvalidCharacter)
        );
    }

    #[test]
    fn decode_rejects_non_hex_characters() {
        assert!(hex_decode("gg").is_err());
        assert!(hex_decode("0x").is_err());
        assert!(hex_decode(" a").is_err());
        assert!(hex_decode("a!").is_err());
    }

    // --- Display ---

    #[test]
    fn error_display_messages() {
        let odd = HexDecodeError::new(HexDecodeErrorKind::OddLength);
        assert_eq!(odd.to_string(), "hex: odd-length input");

        let invalid = HexDecodeError::new(HexDecodeErrorKind::InvalidCharacter);
        assert_eq!(invalid.to_string(), "hex: invalid character in input");
    }

    #[test]
    fn error_implements_std_error() {
        let err: Box<dyn std::error::Error> =
            Box::new(HexDecodeError::new(HexDecodeErrorKind::InvalidCharacter));
        let _ = err.to_string();
    }
}