use thiserror::Error;
const ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
pub const CROCKFORD_ENCODED_LENGTH_FOR_UUID: usize = 26;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdPrefix {
Poe,
}
impl IdPrefix {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
IdPrefix::Poe => "poe",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum IdError {
#[error("crockford-base32: expected 16 bytes, got {0}")]
EncodeWrongByteLength(usize),
#[error("crockford-base32: expected 26-char input, got {0}")]
DecodeWrongLength(usize),
#[error("crockford-base32: invalid character {0:?} at index {1}")]
InvalidCharacter(char, usize),
#[error("crockford-base32: non-zero pad bits at end of input")]
NonZeroPadBits,
#[error("prefixed-id: not a canonical hyphenated UUID: {0:?}")]
NotCanonicalUuid(String),
#[error("prefixed-id: expected 16 decoded bytes, got {0}")]
DecodedNotSixteenBytes(usize),
#[error("prefixed-id: missing prefix separator in {0:?}")]
MissingSeparator(String),
#[error("prefixed-id: expected prefix {0:?}, got {1:?}")]
PrefixMismatch(String, String),
}
fn decode_symbol(ch: char) -> Option<u8> {
match ch {
'0'..='9' => Some(ch as u8 - b'0'),
'a'..='h' => Some(ch as u8 - b'a' + 10),
'A'..='H' => Some(ch as u8 - b'A' + 10),
'j' | 'k' => Some(ch as u8 - b'j' + 18),
'J' | 'K' => Some(ch as u8 - b'J' + 18),
'm' | 'n' => Some(ch as u8 - b'm' + 20),
'M' | 'N' => Some(ch as u8 - b'M' + 20),
'p'..='t' => Some(ch as u8 - b'p' + 22),
'P'..='T' => Some(ch as u8 - b'P' + 22),
'v'..='z' => Some(ch as u8 - b'v' + 27),
'V'..='Z' => Some(ch as u8 - b'V' + 27),
'i' | 'I' | 'l' | 'L' => Some(1),
'o' | 'O' => Some(0),
_ => None,
}
}
#[must_use]
pub fn encode_bytes_variable_length(bytes: &[u8]) -> String {
let mut bits: u32 = 0;
let mut bit_count: u32 = 0;
let mut out = String::new();
for &byte in bytes {
bits = (bits << 8) | u32::from(byte);
bit_count += 8;
while bit_count >= 5 {
bit_count -= 5;
let idx = ((bits >> bit_count) & 0x1f) as usize;
out.push(ALPHABET[idx] as char);
}
}
if bit_count > 0 {
let idx = ((bits << (5 - bit_count)) & 0x1f) as usize;
out.push(ALPHABET[idx] as char);
}
out
}
pub fn encode_bytes(bytes: &[u8]) -> Result<String, IdError> {
if bytes.len() != 16 {
return Err(IdError::EncodeWrongByteLength(bytes.len()));
}
Ok(encode_bytes_variable_length(bytes))
}
pub fn decode_bytes(encoded: &str) -> Result<[u8; 16], IdError> {
if encoded.chars().count() != CROCKFORD_ENCODED_LENGTH_FOR_UUID {
return Err(IdError::DecodeWrongLength(encoded.chars().count()));
}
let mut out = [0u8; 16];
let mut bits: u32 = 0;
let mut bit_count: u32 = 0;
let mut out_idx = 0;
for (i, ch) in encoded.chars().enumerate() {
let value = decode_symbol(ch).ok_or(IdError::InvalidCharacter(ch, i))?;
bits = (bits << 5) | u32::from(value);
bit_count += 5;
if bit_count >= 8 {
bit_count -= 8;
out[out_idx] = ((bits >> bit_count) & 0xff) as u8;
out_idx += 1;
}
}
if bit_count != 2 || (bits & 0x3) != 0 {
return Err(IdError::NonZeroPadBits);
}
Ok(out)
}
fn uuid_string_to_bytes(uuid: &str) -> Result<[u8; 16], IdError> {
let hyphen_count = uuid.bytes().filter(|&b| b == b'-').count();
let stripped: String = uuid.chars().filter(|&c| c != '-').collect();
let lowered = stripped.to_lowercase();
let is_32_hex =
lowered.len() == 32 && lowered.bytes().all(|b| b.is_ascii_hexdigit() && b != b'-');
if !is_32_hex || hyphen_count != 4 {
return Err(IdError::NotCanonicalUuid(uuid.to_string()));
}
let mut out = [0u8; 16];
for (i, byte) in out.iter_mut().enumerate() {
*byte = u8::from_str_radix(&lowered[i * 2..i * 2 + 2], 16)
.map_err(|_| IdError::NotCanonicalUuid(uuid.to_string()))?;
}
Ok(out)
}
fn bytes_to_uuid_string(bytes: &[u8]) -> Result<String, IdError> {
if bytes.len() != 16 {
return Err(IdError::DecodedNotSixteenBytes(bytes.len()));
}
let h: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
Ok(format!(
"{}-{}-{}-{}-{}",
&h[0..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..]
))
}
pub fn encode_prefixed_id(prefix: &str, uuid: &str) -> Result<String, IdError> {
let bytes = uuid_string_to_bytes(uuid)?;
let encoded = encode_bytes_variable_length(&bytes);
Ok(format!("{prefix}_{encoded}"))
}
pub fn decode_prefixed_id(prefix: &str, encoded: &str) -> Result<String, IdError> {
let sep = encoded
.find('_')
.ok_or_else(|| IdError::MissingSeparator(encoded.to_string()))?;
let actual_prefix = &encoded[..sep];
if actual_prefix != prefix {
return Err(IdError::PrefixMismatch(
prefix.to_string(),
actual_prefix.to_string(),
));
}
let body = &encoded[sep + 1..];
let bytes = decode_bytes(body)?;
bytes_to_uuid_string(&bytes)
}
#[must_use]
pub fn is_prefixed_id(prefix: &str, candidate: &str) -> bool {
let head = format!("{prefix}_");
let Some(body) = candidate.strip_prefix(&head) else {
return false;
};
body.len() == 26 && body.bytes().all(is_strict_crockford_lower)
}
fn is_strict_crockford_lower(b: u8) -> bool {
matches!(b,
b'0'..=b'9'
| b'a'..=b'h'
| b'j' | b'k'
| b'm' | b'n'
| b'p'..=b't'
| b'v'..=b'z'
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn poe_prefix_string_is_exact() {
assert_eq!(IdPrefix::Poe.as_str(), "poe");
}
#[test]
fn encodes_zero_and_ff_payloads() {
assert_eq!(encode_bytes(&[0u8; 16]).unwrap(), "0".repeat(26));
let ff = encode_bytes(&[0xffu8; 16]).unwrap();
assert_eq!(&ff[..25], "z".repeat(25));
assert_eq!(&ff[25..], "w");
}
#[test]
fn encode_rejects_non_sixteen_bytes() {
assert_eq!(
encode_bytes(&[0u8; 15]),
Err(IdError::EncodeWrongByteLength(15))
);
assert_eq!(
encode_bytes(&[0u8; 17]),
Err(IdError::EncodeWrongByteLength(17))
);
}
#[test]
fn decode_round_trips_and_accepts_aliases() {
let bytes = [0xffu8; 16];
let encoded = encode_bytes(&bytes).unwrap();
assert_eq!(decode_bytes(&encoded).unwrap(), bytes);
assert_eq!(decode_bytes(&encoded.to_uppercase()).unwrap(), bytes);
}
#[test]
fn decode_rejects_u_and_wrong_length_and_pad_bits() {
let zero = encode_bytes(&[0u8; 16]).unwrap();
let bad = format!("u{}", &zero[1..]);
assert!(matches!(
decode_bytes(&bad),
Err(IdError::InvalidCharacter('u', 0))
));
assert_eq!(decode_bytes("0").err(), Some(IdError::DecodeWrongLength(1)));
let tampered = format!("{}z", &zero[..25]);
assert_eq!(decode_bytes(&tampered).err(), Some(IdError::NonZeroPadBits));
}
#[test]
fn prefixed_id_round_trips() {
let uuid = "01977c4a-0066-7777-aaaa-bbbbbbbbbbbb";
let encoded = encode_prefixed_id("poe", uuid).unwrap();
assert!(encoded.starts_with("poe_"));
assert_eq!(decode_prefixed_id("poe", &encoded).unwrap(), uuid);
}
#[test]
fn prefixed_id_rejects_bad_prefix_and_separator() {
let encoded = encode_prefixed_id("poe", "01977c4a-0066-7777-aaaa-bbbbbbbbbbbb").unwrap();
assert!(matches!(
decode_prefixed_id("acct", &encoded),
Err(IdError::PrefixMismatch(_, _))
));
assert!(matches!(
decode_prefixed_id("poe", "poenoseparatorhere00000000000000"),
Err(IdError::MissingSeparator(_))
));
}
#[test]
fn encode_rejects_malformed_uuid() {
assert!(matches!(
encode_prefixed_id("poe", "not-a-uuid"),
Err(IdError::NotCanonicalUuid(_))
));
assert!(matches!(
encode_prefixed_id("poe", "01977c4a00667777aaaabbbbbbbbbbbb"),
Err(IdError::NotCanonicalUuid(_))
));
}
#[test]
fn is_prefixed_id_guard_behaviour() {
let encoded = encode_prefixed_id("poe", "01977c4a-0066-7777-aaaa-bbbbbbbbbbbb").unwrap();
assert!(is_prefixed_id("poe", &encoded));
assert!(!is_prefixed_id("acct", &encoded));
assert!(!is_prefixed_id("poe", &encoded.to_uppercase()));
assert!(!is_prefixed_id("poe", &format!("{encoded}\n")));
let body = "0".repeat(26);
assert!(!is_prefixed_id(
"poe",
&format!("poe_{}I{}", &body[..5], &body[6..])
));
assert!(!is_prefixed_id(
"poe",
&format!("poe_{}u{}", &body[..5], &body[6..])
));
}
}