use std::{borrow::Cow, fmt::Display, io::Read};
#[derive(Clone, PartialEq, Eq)]
#[cfg_attr(test, derive(Debug))]
pub struct ModHex<'a> {
bytes: Cow<'a, [u8]>,
}
#[derive(Debug, PartialEq)]
pub enum Error {
InvalidCharacter(char),
InvalidLength,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::InvalidCharacter(invalid_char) => {
f.write_fmt(format_args!("Invalid character '{invalid_char}'"))
}
Error::InvalidLength => f.write_str("Invalid modhex length"),
}
}
}
impl std::error::Error for Error {}
const MODHEX_TO_HEX: [char; 16] = [
'c', 'b', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'n', 'r', 't', 'u', 'v',
];
impl ModHex<'_> {
pub fn is_valid(modhex_str: &str) -> Result<(), Error> {
if !modhex_str.len().is_multiple_of(2) || modhex_str.is_empty() {
return Err(Error::InvalidLength);
}
for modhex_char in modhex_str.chars() {
if !('b'..='v').contains(&modhex_char)
|| modhex_char == 'm'
|| modhex_char == 'o'
|| modhex_char == 'p'
|| modhex_char == 'q'
|| modhex_char == 's'
{
return Err(Error::InvalidCharacter(modhex_char));
}
}
Ok(())
}
#[inline]
#[must_use]
pub fn to_owned(&self) -> ModHex<'static> {
ModHex {
bytes: Cow::Owned(self.bytes.to_vec()),
}
}
#[inline]
#[must_use]
pub fn bytes_len(&self) -> usize {
self.bytes.len()
}
#[inline]
#[must_use]
pub fn modhex_len(&self) -> usize {
self.bytes.len() * 2
}
#[inline]
#[must_use]
pub fn raw_bytes(&self) -> &[u8] {
&self.bytes
}
}
impl Display for ModHex<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for byte in self.bytes.iter() {
let high_nibble = (byte >> 4) & 0x0f;
let low_nibble = byte & 0x0f;
write!(
f,
"{}{}",
MODHEX_TO_HEX[high_nibble as usize], MODHEX_TO_HEX[low_nibble as usize]
)?;
}
Ok(())
}
}
impl<'a> From<&'a [u8]> for ModHex<'a> {
fn from(value: &'a [u8]) -> Self {
Self {
bytes: Cow::Borrowed(value),
}
}
}
impl TryFrom<&str> for ModHex<'_> {
type Error = Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
ModHex::is_valid(value)?;
let mut bytes = Box::new_uninit_slice(value.len() / 2);
for (modhex_offset, modhex_char) in value.chars().enumerate() {
#[expect(
clippy::cast_possible_truncation,
reason = "MODHEX_TO_HEX has only 16 values so cast to u8 is safe"
)]
for (hex_value, &mapping_char) in MODHEX_TO_HEX.iter().enumerate() {
if mapping_char == modhex_char {
if modhex_offset % 2 == 0 {
bytes[modhex_offset / 2].write((hex_value as u8) << 4);
} else {
unsafe { *bytes[modhex_offset / 2].assume_init_mut() |= hex_value as u8 }
}
}
}
}
Ok(Self {
bytes: unsafe { Cow::Owned(bytes.assume_init().into_vec()) },
})
}
}
impl Read for ModHex<'_> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let bytes_to_copy = self.bytes_len().min(buf.len());
buf.copy_from_slice(&self.bytes[0..bytes_to_copy]);
Ok(bytes_to_copy)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_from_slice() {
let bytes = [0x12, 0x34, 0x56];
let modhex = ModHex::from(&bytes[..]);
assert_eq!(*modhex.raw_bytes(), bytes);
}
#[test]
fn test_try_from_valid_string() {
let modhex_str = "tett";
let modhex = ModHex::try_from(modhex_str).unwrap();
assert_eq!(modhex.bytes_len(), 2);
assert_eq!(modhex.modhex_len(), 4);
assert_eq!(*modhex.raw_bytes(), [0xd3, 0xdd]);
}
#[test]
fn test_try_from_invalid_character() {
matches!(
ModHex::try_from("testtab").unwrap_err(),
Error::InvalidCharacter('a')
);
}
#[test]
fn test_try_from_invalid_length() {
assert!(matches!(
ModHex::try_from("cde").unwrap_err(),
Error::InvalidLength
));
assert!(matches!(
ModHex::try_from("").unwrap_err(),
Error::InvalidLength
));
}
#[test]
fn test_is_valid() {
assert!(ModHex::is_valid("cbdefghijklnrtuv").is_ok());
assert!(ModHex::is_valid("c").is_err()); assert!(ModHex::is_valid("invalid").is_err()); }
#[test]
fn test_display() {
let bytes = [0x12, 0x34, 0x56];
let modhex = ModHex::from(&bytes[..]);
assert_eq!(format!("{}", modhex), "bdefgh");
}
#[test]
fn test_round_trip() {
let original_modhex = "cbdefghijklnrtuv";
let modhex = ModHex::try_from(original_modhex).unwrap();
let display_string = format!("{}", modhex);
assert_eq!(display_string, original_modhex);
let modhex_from_bytes = ModHex::from(&modhex.bytes[..]);
assert_eq!(
*modhex_from_bytes.raw_bytes(),
[0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]
);
}
#[test]
fn test_to_owned() {
let bytes = Box::new([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]);
let bytes_clone = bytes.clone();
let modhex = ModHex::from(&bytes[..]);
let owned_modhex = modhex.to_owned();
drop(bytes);
assert_eq!(*owned_modhex.raw_bytes(), bytes_clone[..]);
}
#[test]
#[ignore]
fn test_parse_performance() {
use rand::Rng;
use std::time::Instant;
const NUM_OTPS: usize = 100_000;
let mut rng = rand::rng();
let mut otps = Vec::with_capacity(NUM_OTPS);
for _ in 0..NUM_OTPS {
let otp: String = (0..44)
.map(|_| MODHEX_TO_HEX[rng.random_range(0..MODHEX_TO_HEX.len())])
.collect();
otps.push(otp);
}
let start = Instant::now();
for otp_str in &otps {
std::hint::black_box(unsafe { ModHex::try_from(otp_str.as_str()).unwrap_unchecked() });
}
let duration = start.elapsed();
println!(
"Average: {:.2} µs per OTP parsing",
duration.as_micros() as f64 / NUM_OTPS as f64
);
}
}