use alloc::{string::String, vec::Vec};
use crate::consensus::crc32;
use crate::constants::BYTES_INDEXED_BY_HASH;
pub use crate::constants::MINIMALS;
pub use crate::constants::WORDS;
use crate::error::{Error, ErrorKind, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Style {
Standard,
Uri,
Minimal,
}
#[must_use]
pub fn encode(data: &[u8], style: Style) -> String {
let checksum = crc32::checksum(data).to_be_bytes();
encode_words(data.iter().chain(checksum.iter()).copied(), style)
}
#[must_use]
fn encode_raw(data: &[u8], style: Style) -> String {
encode_words(data.iter().copied(), style)
}
#[must_use]
pub fn checksum(data: &[u8], style: Style) -> String {
encode_raw(&crc32::checksum(data).to_be_bytes(), style)
}
#[must_use]
pub const fn encoded_len(len: usize, style: Style) -> usize {
if len == 0 {
return 0;
}
match style {
Style::Standard | Style::Uri => len * 5 - 1,
Style::Minimal => len * 2,
}
}
#[must_use]
pub fn identifier(data: [u8; 4]) -> String {
encode_raw(&data, Style::Standard)
}
fn encode_words(data: impl Iterator<Item = u8>, style: Style) -> String {
let words: Vec<&str> = match style {
Style::Standard | Style::Uri => data.map(word_at).collect(),
Style::Minimal => data.map(minimal_at).collect(),
};
let separator = match style {
Style::Standard => " ",
Style::Uri => "-",
Style::Minimal => "",
};
words.join(separator)
}
pub fn decode(encoded: &str, style: Style) -> Result<Vec<u8>> {
if !encoded.is_ascii() {
return Err(Error::new(ErrorKind::NonAscii));
}
let lowered = encoded.to_ascii_lowercase();
let separator = match style {
Style::Standard => ' ',
Style::Uri => '-',
Style::Minimal => return decode_minimal(&lowered),
};
if lowered.is_empty() {
return Err(Error::new(ErrorKind::InvalidBytewordsChecksum));
}
decode_parts(lowered.split(separator), false)
}
fn decode_minimal(encoded: &str) -> Result<Vec<u8>> {
if !encoded.len().is_multiple_of(2) {
return Err(Error::new(ErrorKind::InvalidBytewordsLength));
}
let parts = (0..encoded.len())
.step_by(2)
.map(|idx| encoded.get(idx..idx + 2).unwrap_or(""));
decode_parts(parts, true)
}
#[inline]
fn word_at(b: u8) -> &'static str {
WORDS.get(usize::from(b)).copied().unwrap_or("")
}
#[inline]
fn minimal_at(b: u8) -> &'static str {
MINIMALS.get(usize::from(b)).copied().unwrap_or("")
}
fn encoded_byte(part: &str, minimal: bool) -> Option<u8> {
let bytes = part.as_bytes();
let expected_len = if minimal { 2 } else { 4 };
if bytes.len() != expected_len {
return None;
}
let first = *bytes.first()?;
let last = *bytes.get(expected_len - 1)?;
let hash = usize::try_from((25 * u32::from(first) + 11 * u32::from(last)) % 628).ok()?;
let byte = BYTES_INDEXED_BY_HASH.get(hash).copied().flatten()?;
let expected = if minimal {
minimal_at(byte)
} else {
word_at(byte)
};
(part == expected).then_some(byte)
}
fn decode_parts<'a, I>(parts: I, minimal: bool) -> Result<Vec<u8>>
where
I: Iterator<Item = &'a str>,
{
let data: Vec<u8> = parts
.map(|part| encoded_byte(part, minimal))
.collect::<Option<Vec<_>>>()
.ok_or_else(|| Error::new(ErrorKind::InvalidWord))?;
strip_checksum(data)
}
fn strip_checksum(mut data: Vec<u8>) -> Result<Vec<u8>> {
if data.len() < 4 {
return Err(Error::new(ErrorKind::InvalidBytewordsChecksum));
}
let split = data.len() - 4;
let (payload, checksum) = data.split_at(split);
if crc32::checksum(payload).to_be_bytes() == *checksum {
data.truncate(split);
Ok(data)
} else {
Err(Error::new(ErrorKind::InvalidBytewordsChecksum))
}
}
#[must_use]
pub fn canonicalize(token: &str) -> Option<&'static str> {
if !token.is_ascii() {
return None;
}
let lower = token.to_ascii_lowercase();
let bytes = lower.as_bytes();
match bytes.len() {
4 => {
let byte = encoded_byte(&lower, false)?;
Some(word_at(byte))
}
3 => canonicalize_three_letter(bytes),
2 => {
let byte = encoded_byte(&lower, true)?;
Some(word_at(byte))
}
_ => None,
}
}
fn canonicalize_three_letter(bytes: &[u8]) -> Option<&'static str> {
let mut found: Option<&'static str> = None;
for word in &WORDS {
let w = word.as_bytes();
let first3 = w.get(..3) == Some(bytes);
let last3 = w.get(1..4) == Some(bytes);
if !(first3 || last3) {
continue;
}
if found.is_some() {
return None;
}
found = Some(*word);
}
found
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bytewords() {
let input = vec![0, 1, 2, 128, 255];
assert_eq!(
encode(&input, Style::Standard),
"able acid also lava zoom jade need echo taxi"
);
assert_eq!(
encode(&input, Style::Uri),
"able-acid-also-lava-zoom-jade-need-echo-taxi"
);
assert_eq!(encode(&input, Style::Minimal), "aeadaolazmjendeoti");
assert_eq!(
decode(
"able acid also lava zoom jade need echo taxi",
Style::Standard
)
.unwrap(),
input
);
assert_eq!(
decode("able-acid-also-lava-zoom-jade-need-echo-taxi", Style::Uri).unwrap(),
input
);
assert_eq!(decode("aeadaolazmjendeoti", Style::Minimal).unwrap(), input);
decode(&encode(&[], Style::Minimal), Style::Minimal).unwrap();
assert_eq!(
decode(
"able acid also lava zero jade need echo wolf",
Style::Standard
)
.unwrap_err(),
Error::new(ErrorKind::InvalidBytewordsChecksum)
);
assert_eq!(
decode("axxe tied also webs lung", Style::Standard).unwrap_err(),
Error::new(ErrorKind::InvalidWord)
);
assert_eq!(
decode("aea", Style::Minimal).unwrap_err(),
Error::new(ErrorKind::InvalidBytewordsLength)
);
assert_eq!(
decode("â‚¿", Style::Standard).unwrap_err(),
Error::new(ErrorKind::NonAscii)
);
}
#[test]
fn test_encoding_long() {
let input: [u8; 100] = [
245, 215, 20, 198, 241, 235, 69, 59, 209, 205, 165, 18, 150, 158, 116, 135, 229, 212,
19, 159, 17, 37, 239, 240, 253, 11, 109, 191, 37, 242, 38, 120, 223, 41, 156, 189, 242,
254, 147, 204, 66, 163, 216, 175, 191, 72, 169, 54, 32, 60, 144, 230, 210, 137, 184,
197, 33, 113, 88, 14, 157, 31, 177, 46, 1, 115, 205, 69, 225, 150, 65, 235, 58, 144,
65, 240, 133, 69, 113, 247, 63, 53, 242, 165, 160, 144, 26, 13, 79, 237, 133, 71, 82,
69, 254, 165, 138, 41, 85, 24,
];
let encoded = "yank toys bulb skew when warm free fair tent swan \
open brag mint noon jury list view tiny brew note \
body data webs what zinc bald join runs data whiz \
days keys user diet news ruby whiz zone menu surf \
flew omit trip pose runs fund part even crux fern \
math visa tied loud redo silk curl jugs hard beta \
next cost puma drum acid junk swan free very mint \
flap warm fact math flap what limp free jugs yell \
fish epic whiz open numb math city belt glow wave \
limp fuel grim free zone open love diet gyro cats \
fizz holy city puff";
let encoded_minimal = "yktsbbswwnwmfefrttsnonbgmtnnjyltvwtybwne\
bydawswtzcbdjnrsdawzdsksurdtnsrywzzemusf\
fwottppersfdptencxfnmhvatdldroskcljshdba\
ntctpadmadjksnfevymtfpwmftmhfpwtlpfejsyl\
fhecwzonnbmhcybtgwwelpflgmfezeonledtgocs\
fzhycypf";
assert_eq!(decode(encoded, Style::Standard).unwrap(), input.to_vec());
assert_eq!(
decode(encoded_minimal, Style::Minimal).unwrap(),
input.to_vec()
);
assert_eq!(encode(&input, Style::Standard), encoded);
assert_eq!(encode(&input, Style::Minimal), encoded_minimal);
}
#[test]
fn test_single_zero() {
assert_eq!(encode(&[0], Style::Minimal), "aetdaowslg");
assert_eq!(decode("aetdaowslg", Style::Minimal).unwrap(), vec![0]);
}
#[test]
fn test_case_insensitive_decode() {
let input = vec![0_u8, 1, 2];
let standard = encode(&input, Style::Standard);
let minimal = encode(&input, Style::Minimal);
assert_eq!(
decode(&standard.to_ascii_uppercase(), Style::Standard).unwrap(),
input
);
assert_eq!(
decode(&minimal.to_ascii_uppercase(), Style::Minimal).unwrap(),
input
);
}
#[test]
fn test_checksum_identifier_encoded_len_canonicalize() {
assert_eq!(checksum(&[0], Style::Standard), "tied also webs lung");
assert_eq!(identifier([0, 1, 2, 3]), "able acid also apex");
assert_eq!(encoded_len(0, Style::Standard), 0);
assert_eq!(encoded_len(5, Style::Standard), 24);
assert_eq!(encoded_len(5, Style::Minimal), 10);
assert_eq!(encode_raw(&[0], Style::Minimal), "ae");
assert_eq!(canonicalize("ABLE"), Some("able"));
assert_eq!(canonicalize("ae"), Some("able"));
assert_eq!(canonicalize("abl"), Some("able"));
assert_eq!(canonicalize("ble"), Some("able"));
assert_eq!(canonicalize("nope"), None);
assert_eq!(canonicalize("a"), None);
}
}