#![forbid(unsafe_code)]
use std::fmt;
#[rustfmt::skip]
pub const ALPHABET: [&str; 256] = [
"access", "account", "action", "address", "album", "android", "application", "area",
"array", "article", "association", "author", "award", "background", "band", "black",
"board", "body", "border", "break", "build", "building", "business", "button", "call",
"card", "career", "category", "census", "center", "central", "century", "change", "character",
"check", "city", "class", "click", "client", "close", "club", "code", "college", "color",
"column", "command", "common", "community", "company", "components", "console", "container",
"content", "control", "council", "count", "country", "course", "data", "database", "density",
"department", "description", "design", "development", "device", "director", "display",
"district", "division", "document", "door", "double", "download", "early", "education",
"element", "email", "error", "events", "example", "export", "express", "face", "features",
"field", "film", "first", "float", "food", "football", "force", "form", "format", "function",
"future", "games", "general", "green", "group", "head", "header", "height", "help", "high",
"history", "home", "host", "house", "households", "images", "import", "important", "income",
"index", "info", "information", "input", "install", "island", "king", "label", "language",
"large", "league", "length", "level", "library", "license", "life", "light", "list",
"local", "location", "login", "love", "management", "march", "market", "master", "material",
"math", "median", "members", "message", "method", "million", "models", "money", "music",
"network", "news", "north", "note", "number", "object", "office", "options", "package",
"page", "password", "people", "period", "person", "places", "play", "players", "population",
"port", "position", "power", "press", "price", "print", "println", "process", "production",
"products", "program", "project", "property", "published", "query", "question", "range",
"records", "references", "region", "register", "render", "report", "request", "research",
"response", "results", "return", "review", "river", "role", "room", "router", "school",
"science", "score", "script", "search", "season", "section", "select", "send", "series",
"services", "session", "share", "social", "society", "software", "song", "source", "south",
"space", "span", "species", "square", "station", "story", "street", "string", "students",
"study", "style", "success", "system", "table", "target", "task", "team", "television",
"template", "title", "token", "track", "train", "training", "union", "university", "update",
"username", "users", "version", "video", "village", "website", "width", "window", "world",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecodeError {
UnknownWord { position: usize, word: String },
Empty,
}
impl fmt::Display for DecodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownWord { position, word } => write!(
f,
"`{word}` (word {}) is not in the unigram alphabet",
position + 1
),
Self::Empty => f.write_str("no unigram words found"),
}
}
}
impl std::error::Error for DecodeError {}
fn split_words(text: &str) -> impl Iterator<Item = &str> {
text.split(|c: char| !c.is_ascii_alphabetic())
.filter(|word| !word.is_empty())
}
pub fn encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 8);
for (index, byte) in bytes.iter().enumerate() {
if index > 0 {
out.push(' ');
}
out.push_str(ALPHABET[*byte as usize]);
}
out
}
pub fn decode(text: &str) -> Result<Vec<u8>, DecodeError> {
let mut bytes = Vec::new();
for (position, word) in split_words(text).enumerate() {
let lowered = word.to_ascii_lowercase();
match ALPHABET.binary_search(&lowered.as_str()) {
Ok(index) => bytes.push(index as u8),
Err(_) => {
return Err(DecodeError::UnknownWord {
position,
word: word.to_string(),
})
}
}
}
if bytes.is_empty() {
return Err(DecodeError::Empty);
}
Ok(bytes)
}
pub fn mint(bytes: usize) -> String {
let mut buffer = vec![0u8; bytes];
getrandom::fill(&mut buffer).expect("OS entropy source unavailable");
encode(&buffer)
}
pub fn normalize(text: &str) -> String {
text.split_whitespace()
.map(|part| part.to_ascii_lowercase())
.collect::<Vec<_>>()
.join(" ")
}
pub fn matches(issued: &str, presented: &str) -> bool {
if let (Ok(issued_bytes), Ok(presented_bytes)) = (decode(issued), decode(presented)) {
return issued_bytes == presented_bytes;
}
normalize(issued) == normalize(presented)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_alphabet_is_sorted_unique_and_plain_lowercase() {
let mut sorted = ALPHABET;
sorted.sort_unstable();
assert_eq!(sorted, ALPHABET, "binary_search requires sorted order");
let unique: std::collections::HashSet<_> = ALPHABET.iter().collect();
assert_eq!(unique.len(), ALPHABET.len());
for word in ALPHABET {
assert!(
word.len() >= 4 && word.len() <= 11 && word.bytes().all(|b| b.is_ascii_lowercase()),
"`{word}`"
);
}
}
#[test]
fn no_two_entries_are_within_one_edit_of_each_other() {
fn within_one_edit(a: &str, b: &str) -> bool {
let (a, b) = if a.len() > b.len() { (b, a) } else { (a, b) };
let (short, long) = (a.as_bytes(), b.as_bytes());
match long.len() - short.len() {
0 => short.iter().zip(long).filter(|(x, y)| x != y).count() <= 1,
1 => {
let skip = short.iter().zip(long).take_while(|(x, y)| x == y).count();
short[skip..] == long[skip + 1..]
}
_ => false,
}
}
for (i, a) in ALPHABET.iter().enumerate() {
for b in &ALPHABET[i + 1..] {
assert!(!within_one_edit(a, b), "`{a}` and `{b}` are one edit apart");
}
}
}
#[test]
fn every_byte_round_trips() {
let all: Vec<u8> = (0..=255).collect();
assert_eq!(decode(&encode(&all)).unwrap(), all);
}
#[test]
fn a_single_byte_round_trips_without_separators() {
let encoded = encode(&[7]);
assert!(!encoded.contains(' '));
assert_eq!(decode(&encoded).unwrap(), vec![7]);
}
#[test]
fn decoding_survives_the_mangling_a_round_trip_introduces() {
let bytes = [0x3d, 0x9a, 0x00, 0xff];
let encoded = encode(&bytes);
for mangled in [
encoded.to_uppercase(),
format!(" {encoded} "),
encoded.replace(' ', "-"),
encoded.replace(' ', ", "),
encoded.replace(' ', "\n"),
format!("\"{}\"", encoded.replace(' ', " ")),
] {
assert_eq!(decode(&mangled).unwrap(), bytes, "{mangled}");
}
}
#[test]
fn an_unknown_word_is_refused_and_named() {
let encoded = format!("{} zzzz {}", ALPHABET[1], ALPHABET[2]);
assert_eq!(
decode(&encoded),
Err(DecodeError::UnknownWord {
position: 1,
word: "zzzz".to_string(),
})
);
}
#[test]
fn a_one_character_slip_is_refused_rather_than_read_as_another_byte() {
assert!(matches!(
decode("accesx"),
Err(DecodeError::UnknownWord { .. })
));
}
#[test]
fn an_empty_value_is_refused() {
assert_eq!(decode(""), Err(DecodeError::Empty));
assert_eq!(decode(" -- \n"), Err(DecodeError::Empty));
}
#[test]
fn mint_produces_one_word_per_requested_byte() {
let minted = mint(4);
assert_eq!(minted.split(' ').count(), 4, "{minted}");
assert_eq!(decode(&minted).unwrap().len(), 4);
assert_ne!(mint(8), mint(8));
}
#[test]
fn matching_tolerates_mangling_of_an_encoded_value() {
let issued = mint(4);
assert!(matches(&issued, &issued));
assert!(matches(&issued, &issued.to_uppercase()));
assert!(matches(
&issued,
&format!(" {} ", issued.replace(' ', " - "))
));
assert!(!matches(&issued, &mint(4)));
}
#[test]
fn matching_still_compares_values_that_are_not_encoded_at_all() {
let legacy = "3925ca9a0065442496cc231d6ae48870";
assert!(matches(legacy, legacy));
assert!(matches(legacy, &format!(" {} ", legacy.to_uppercase())));
assert!(!matches(legacy, "3925ca9a0065442496cc231d6ae48871"));
assert!(!matches(legacy, &mint(4)));
}
#[test]
fn normalize_leaves_a_non_encoded_string_intact() {
assert_eq!(normalize(" 3925CA9A-0065 "), "3925ca9a-0065");
}
}