use crate::bitstream::{BitReader, BitWriter};
use crate::error::Error;
const CODEX32_ALPHABET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const HRP: &str = "md";
pub(crate) const REGULAR_CHECKSUM_SYMBOLS: usize = 13;
pub(crate) const REGULAR_DATA_SYMBOLS_MAX: usize = 80;
pub(crate) const REGULAR_CODE_SYMBOLS_MAX: usize =
REGULAR_DATA_SYMBOLS_MAX + REGULAR_CHECKSUM_SYMBOLS;
fn bits_to_symbols(payload_bytes: &[u8], bit_count: usize) -> Result<Vec<u8>, Error> {
let symbol_count = bit_count.div_ceil(5);
let mut r = BitReader::with_bit_limit(payload_bytes, bit_count);
let mut symbols = Vec::with_capacity(symbol_count);
for _ in 0..symbol_count {
let take = r.remaining_bits().min(5);
let val = if take == 0 {
0
} else {
r.read_bits(take)? as u8
};
let symbol = (val << (5 - take as u32)) & 0x1F;
symbols.push(symbol);
}
Ok(symbols)
}
fn symbols_to_bytes(symbols: &[u8]) -> Vec<u8> {
let mut w = BitWriter::new();
for &s in symbols {
w.write_bits((s & 0x1F) as u64, 5);
}
w.into_bytes()
}
fn symbol_to_char(s: u8) -> char {
CODEX32_ALPHABET[(s & 0x1F) as usize] as char
}
fn char_to_symbol(c: char) -> Option<u8> {
let lc = c.to_ascii_lowercase() as u8;
CODEX32_ALPHABET
.iter()
.position(|&b| b == lc)
.map(|i| i as u8)
}
pub fn wrap_payload(payload_bytes: &[u8], bit_count: usize) -> Result<String, Error> {
let data_symbols = bits_to_symbols(payload_bytes, bit_count)?;
if data_symbols.len() > REGULAR_DATA_SYMBOLS_MAX {
return Err(Error::PayloadTooLongForSingleString {
data_symbols: data_symbols.len(),
max: REGULAR_DATA_SYMBOLS_MAX,
});
}
let checksum: [u8; 13] = crate::bch::bch_create_checksum_regular(HRP, &data_symbols);
let mut s =
String::with_capacity(HRP.len() + 1 + data_symbols.len() + REGULAR_CHECKSUM_SYMBOLS);
s.push_str(HRP);
s.push('1'); for sym in &data_symbols {
s.push(symbol_to_char(*sym));
}
for sym in checksum.iter() {
s.push(symbol_to_char(*sym));
}
Ok(s)
}
pub(crate) fn is_mixed_case(s: &str) -> bool {
let mut has_upper = false;
let mut has_lower = false;
for c in s.chars() {
if c.is_ascii_uppercase() {
has_upper = true;
} else if c.is_ascii_lowercase() {
has_lower = true;
}
if has_upper && has_lower {
return true;
}
}
false
}
pub fn unwrap_string(s: &str) -> Result<(Vec<u8>, usize), Error> {
if is_mixed_case(s) {
return Err(Error::Codex32DecodeError(
"string mixes upper and lower case (BIP-173 forbids mixed case)".to_string(),
));
}
let prefix = format!("{}1", HRP);
if !s.to_ascii_lowercase().starts_with(&prefix) {
return Err(Error::Codex32DecodeError(format!(
"string does not start with HRP {prefix}"
)));
}
let symbols_str = &s[prefix.len()..];
let mut symbols = Vec::with_capacity(symbols_str.len());
for c in symbols_str.chars() {
if c.is_whitespace() || c == '-' {
continue;
}
let sym = char_to_symbol(c).ok_or_else(|| {
Error::Codex32DecodeError(format!("character {c:?} not in codex32 alphabet"))
})?;
symbols.push(sym);
}
if symbols.len() > REGULAR_CODE_SYMBOLS_MAX {
return Err(Error::StringSymbolCountOutOfRange {
symbols: symbols.len(),
max: REGULAR_CODE_SYMBOLS_MAX,
});
}
if !crate::bch::bch_verify_regular(HRP, &symbols) {
return Err(Error::Codex32DecodeError(
"BCH checksum verification failed".into(),
));
}
if symbols.len() < REGULAR_CHECKSUM_SYMBOLS {
return Err(Error::Codex32DecodeError(
"string too short for BCH checksum".into(),
));
}
let data_symbols = &symbols[..symbols.len() - REGULAR_CHECKSUM_SYMBOLS];
let bit_count = 5 * data_symbols.len();
Ok((symbols_to_bytes(data_symbols), bit_count))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrap_unwrap_round_trip_57_bits() {
let mut w = BitWriter::new();
w.write_bits(0xDEAD_BEEF_CAFE_BABE_u64 >> 7, 57);
let bytes = w.into_bytes();
let s = wrap_payload(&bytes, 57).unwrap();
assert_eq!(s.len(), 28);
assert!(s.starts_with("md1"));
let (out_bytes, out_bits) = unwrap_string(&s).unwrap();
assert_eq!(out_bits, 60);
assert_eq!(&out_bytes[..7], &bytes[..7]);
assert_eq!(out_bytes[7] & 0x80, bytes[7] & 0x80);
}
#[test]
fn wrap_unwrap_n3_chunk_byte_count_recovers_correctly() {
let bit_count = 37 + 24;
let mut w = BitWriter::new();
w.write_bits(0x1FFF_FFFF_FFFF_u64, 37); w.write_bits(0x00AA_BBCC_u64, 24);
let bytes = w.into_bytes();
assert_eq!(bytes.len(), 8); let s = wrap_payload(&bytes, bit_count).unwrap();
let (_out_bytes, out_bits) = unwrap_string(&s).unwrap();
assert_eq!(out_bits, 65);
let recovered_payload_byte_count = (out_bits - 37) / 8;
assert_eq!(recovered_payload_byte_count, 3);
}
#[test]
fn unwrap_rejects_non_md_string() {
assert!(unwrap_string("xx1qpz9r4cy7").is_err());
}
#[test]
fn unwrap_tolerates_visual_separators() {
let mut w = BitWriter::new();
w.write_bits(0b1010, 4);
let bytes = w.into_bytes();
let s = wrap_payload(&bytes, 4).unwrap();
let mut grouped = String::new();
for (i, c) in s.chars().enumerate() {
grouped.push(c);
if i == 3 {
grouped.push('-');
}
if i == 8 {
grouped.push(' ');
}
}
let _ = unwrap_string(&grouped).unwrap();
}
#[test]
fn wrap_payload_rejects_over_80_data_symbols() {
let bit_count = 81 * 5;
let mut w = BitWriter::new();
let mut remaining = bit_count;
while remaining > 0 {
let take = remaining.min(32);
w.write_bits(0xDEAD_BEEF_u64 & ((1u64 << take) - 1), take);
remaining -= take;
}
let bytes = w.into_bytes();
let got = wrap_payload(&bytes, bit_count);
assert_eq!(
got,
Err(Error::PayloadTooLongForSingleString {
data_symbols: 81,
max: 80,
}),
"81 data symbols must be rejected with the typed cap error"
);
}
#[test]
fn wrap_payload_accepts_exactly_80_data_symbols() {
let bit_count = 80 * 5;
let mut w = BitWriter::new();
let mut remaining = bit_count;
while remaining > 0 {
let take = remaining.min(32);
w.write_bits(0x1234_5678_u64 & ((1u64 << take) - 1), take);
remaining -= take;
}
let bytes = w.into_bytes();
let s = wrap_payload(&bytes, bit_count).expect("80 data symbols is in-domain");
assert_eq!(s.chars().count(), 3 + 80 + REGULAR_CHECKSUM_SYMBOLS);
}
fn clean_md1_with_data_symbols(data_symbols: usize) -> String {
let data: Vec<u8> = (0..data_symbols).map(|i| (i as u8) & 0x1F).collect();
let checksum = crate::bch::bch_create_checksum_regular(HRP, &data);
let mut s = String::new();
s.push_str(HRP);
s.push('1');
for &sym in data.iter().chain(checksum.iter()) {
s.push(symbol_to_char(sym));
}
s
}
#[test]
fn unwrap_string_rejects_clean_over_93_symbol_string() {
let s = clean_md1_with_data_symbols(90);
let codeword_symbols = 90 + REGULAR_CHECKSUM_SYMBOLS;
assert_eq!(codeword_symbols, 103);
match crate::decode::decode_md1_string(&s) {
Err(Error::StringSymbolCountOutOfRange { symbols, max }) => {
assert_eq!(symbols, codeword_symbols);
assert_eq!(max, 93);
}
other => panic!(
"clean over-93-symbol string must be rejected with StringSymbolCountOutOfRange, got {other:?}"
),
}
}
#[test]
fn unwrap_string_accepts_exactly_93_symbol_codeword() {
let s = clean_md1_with_data_symbols(80);
assert_eq!(s.chars().count(), 3 + 80 + REGULAR_CHECKSUM_SYMBOLS);
let (_bytes, bit_count) =
unwrap_string(&s).expect("a 93-symbol legal codeword must still decode");
assert_eq!(bit_count, 5 * 80);
}
}