#![forbid(unsafe_code)]
use std::fmt;
pub const FORMAT_VERSION: u32 = 2;
#[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", "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", "font",
"food", "football", "force", "form", "format", "function", "future", "games", "general",
"green", "group", "head", "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", "module", "money", "month", "music", "network", "news", "north",
"note", "number", "object", "office", "options", "package", "page", "park", "password",
"people", "period", "person", "places", "play", "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",
"union", "university", "update", "username", "users", "version", "video", "village", "website",
"width", "window", "world",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct UnigramId<const N: usize>([u8; N]);
impl<const N: usize> UnigramId<N> {
pub const fn from_bytes(bytes: [u8; N]) -> Self {
Self(bytes)
}
pub fn try_random() -> Result<Self, getrandom::Error> {
let mut bytes = [0u8; N];
getrandom::fill(&mut bytes)?;
Ok(Self(bytes))
}
pub fn parse(text: &str) -> Result<Self, ParseError> {
Self::from_vec(decode(text)?)
}
pub fn recover(text: &str) -> Result<Self, ParseError> {
Self::from_vec(decode_recovered(text)?)
}
pub const fn as_bytes(&self) -> &[u8; N] {
&self.0
}
pub const fn into_bytes(self) -> [u8; N] {
self.0
}
fn from_vec(bytes: Vec<u8>) -> Result<Self, ParseError> {
<[u8; N]>::try_from(bytes.as_slice())
.map(Self)
.map_err(|_| ParseError::WrongLength {
expected: N,
found: bytes.len(),
})
}
}
impl<const N: usize> fmt::Display for UnigramId<N> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, byte) in self.0.iter().enumerate() {
if index > 0 {
f.write_str(" ")?;
}
f.write_str(ALPHABET[*byte as usize])?;
}
Ok(())
}
}
impl<const N: usize> From<[u8; N]> for UnigramId<N> {
fn from(bytes: [u8; N]) -> Self {
Self(bytes)
}
}
impl<const N: usize> From<UnigramId<N>> for [u8; N] {
fn from(id: UnigramId<N>) -> Self {
id.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecodeError {
UnknownWord { position: usize, word: String },
NotCanonical { position: usize },
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::NotCanonical { position } => write!(
f,
"word {} is not in canonical form (lowercase, single-space separated)",
position + 1
),
Self::Empty => f.write_str("no unigram words found"),
}
}
}
impl std::error::Error for DecodeError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
Decode(DecodeError),
WrongLength { expected: usize, found: usize },
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Decode(error) => error.fmt(f),
Self::WrongLength { expected, found } => {
write!(f, "expected {expected} words, found {found}")
}
}
}
}
impl std::error::Error for ParseError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Decode(error) => Some(error),
Self::WrongLength { .. } => None,
}
}
}
impl From<DecodeError> for ParseError {
fn from(error: DecodeError) -> Self {
Self::Decode(error)
}
}
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> {
if text.is_empty() {
return Err(DecodeError::Empty);
}
let mut bytes = Vec::new();
for (position, word) in text.split(' ').enumerate() {
match ALPHABET.binary_search(&word) {
Ok(index) => bytes.push(index as u8),
Err(_) if decodes_ignoring_case(word) => {
return Err(DecodeError::NotCanonical { position })
}
Err(_) => {
return Err(DecodeError::UnknownWord {
position,
word: word.to_string(),
})
}
}
}
Ok(bytes)
}
pub fn decode_recovered(text: &str) -> Result<Vec<u8>, DecodeError> {
let mut bytes = Vec::new();
for (position, word) in text
.split(|c: char| !c.is_ascii_alphabetic())
.filter(|word| !word.is_empty())
.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)
}
fn decodes_ignoring_case(word: &str) -> bool {
word.is_ascii()
&& ALPHABET
.binary_search(&word.to_ascii_lowercase().as_str())
.is_ok()
}
pub fn try_mint(bytes: usize) -> Result<String, getrandom::Error> {
let mut buffer = vec![0u8; bytes];
getrandom::fill(&mut buffer)?;
Ok(encode(&buffer))
}
#[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 the_alphabet_matches_its_frozen_digest() {
const FROZEN: u64 = 0x1771_3c01_9799_607a;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for (index, word) in ALPHABET.iter().enumerate() {
if index > 0 {
hash = (hash ^ u64::from(b'\n')).wrapping_mul(PRIME);
}
for byte in word.bytes() {
hash = (hash ^ u64::from(byte)).wrapping_mul(PRIME);
}
}
assert_eq!(
hash, FROZEN,
"the alphabet changed: every previously issued value now decodes differently"
);
}
#[test]
fn no_two_entries_are_within_one_edit_of_each_other() {
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");
}
}
}
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,
}
}
#[test]
fn no_entry_is_reachable_from_another_by_a_suffix() {
const SUFFIXES: [&str; 15] = [
"s", "es", "ing", "ed", "er", "ers", "ors", "ion", "ions", "ies", "ment", "ments",
"al", "ly", "y",
];
for entry in ALPHABET {
for suffix in SUFFIXES {
for stem in [
entry.to_string(),
entry.strip_suffix('e').unwrap_or(entry).to_string(),
format!("{}i", entry.strip_suffix('y').unwrap_or(entry)),
] {
let derived = format!("{stem}{suffix}");
if derived == entry {
continue;
}
assert!(
ALPHABET.binary_search(&derived.as_str()).is_err(),
"`{entry}` becomes `{derived}` by adding `{suffix}`, and both are entries"
);
}
}
}
}
#[test]
fn every_byte_round_trips() {
let all: Vec<u8> = (0..=255).collect();
assert_eq!(decode(&encode(&all)).unwrap(), all);
assert_eq!(decode_recovered(&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 the_empty_encoding_is_refused_by_both_parsers() {
assert_eq!(encode(&[]), "");
assert_eq!(decode(""), Err(DecodeError::Empty));
assert_eq!(decode_recovered(""), Err(DecodeError::Empty));
assert_eq!(decode_recovered(" -- \n"), Err(DecodeError::Empty));
assert_eq!(try_mint(0).unwrap(), "");
}
#[test]
fn recovery_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_recovered(&mangled).unwrap(), bytes, "{mangled}");
}
}
#[test]
fn the_canonical_parser_refuses_what_recovery_accepts() {
let bytes = [0x3d, 0x9a, 0x00, 0xff];
let encoded = encode(&bytes);
for mangled in [
encoded.to_uppercase(),
format!(" {encoded} "),
encoded.replace(' ', "-"),
encoded.replace(' ', " "),
format!("\"{encoded}\""),
] {
assert!(decode(&mangled).is_err(), "canonical accepted `{mangled}`");
}
assert_eq!(decode(&encoded).unwrap(), bytes);
}
#[test]
fn a_case_slip_is_reported_as_non_canonical_not_unknown() {
let encoded = format!("{} {}", ALPHABET[1], ALPHABET[2].to_uppercase());
assert_eq!(
decode(&encoded),
Err(DecodeError::NotCanonical { position: 1 })
);
assert_eq!(decode_recovered(&encoded).unwrap(), vec![1, 2]);
}
#[test]
fn an_unknown_word_is_refused_and_named() {
let encoded = format!("{} zzzz {}", ALPHABET[1], ALPHABET[2]);
let expected = Err(DecodeError::UnknownWord {
position: 1,
word: "zzzz".to_string(),
});
assert_eq!(decode(&encoded), expected);
assert_eq!(decode_recovered(&encoded), expected);
}
#[test]
fn a_one_character_slip_is_refused_rather_than_read_as_another_byte() {
assert!(matches!(
decode_recovered("accesx"),
Err(DecodeError::UnknownWord { .. })
));
}
#[test]
fn recovery_accepts_ordinary_prose_made_of_alphabet_words() {
assert!(decode_recovered("home page").is_ok());
assert!(decode("home page").is_ok());
}
#[test]
fn an_id_round_trips_through_its_canonical_rendering() {
let id = UnigramId::from_bytes([0x3d, 0x9a, 0x00, 0xff]);
assert_eq!(id.to_string(), "description note access world");
assert_eq!(UnigramId::<4>::parse(&id.to_string()).unwrap(), id);
assert_eq!(
UnigramId::<4>::recover("DESCRIPTION-NOTE-ACCESS-WORLD").unwrap(),
id
);
assert_eq!(id.as_bytes(), &[0x3d, 0x9a, 0x00, 0xff]);
assert_eq!(id.into_bytes(), [0x3d, 0x9a, 0x00, 0xff]);
}
#[test]
fn an_id_of_the_wrong_width_is_refused() {
let five = encode(&[1, 2, 3, 4, 5]);
assert_eq!(
UnigramId::<4>::parse(&five),
Err(ParseError::WrongLength {
expected: 4,
found: 5
})
);
assert!(UnigramId::<6>::parse(&five).is_err());
assert_eq!(
UnigramId::<5>::parse(&five).unwrap().as_bytes(),
&[1, 2, 3, 4, 5]
);
}
#[test]
fn a_random_id_is_the_requested_width_and_not_the_same_twice() {
let id: UnigramId<8> = UnigramId::try_random().unwrap();
assert_eq!(id.to_string().split(' ').count(), 8);
assert_eq!(UnigramId::<8>::parse(&id.to_string()).unwrap(), id);
assert_ne!(
UnigramId::<16>::try_random().unwrap(),
UnigramId::<16>::try_random().unwrap()
);
}
#[test]
fn minting_produces_one_word_per_requested_byte() {
let minted = try_mint(4).unwrap();
assert_eq!(minted.split(' ').count(), 4, "{minted}");
assert_eq!(decode(&minted).unwrap().len(), 4);
}
#[test]
fn no_input_panics_either_parser() {
for text in [
"\u{0}",
"\u{200b}",
"🙂",
"access\u{200b}account",
&"a".repeat(10_000),
&"access ".repeat(1_000),
"-",
" ",
" access ",
] {
let _ = decode(text);
let _ = decode_recovered(text);
let _ = UnigramId::<4>::parse(text);
let _ = UnigramId::<4>::recover(text);
}
}
}