use core::{cmp::Ordering, fmt, str::FromStr};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use derive_more::{From, Into};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
use thiserror::Error;
use uuid::Uuid;
const MAX_ENCODED_LEN: usize = 24;
const UUID_LEN: usize = 16;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, From, Into)]
pub struct KafkaUuid(pub Uuid);
impl KafkaUuid {
pub const ZERO: Self = Self::from_bits(0, 0);
pub const ONE: Self = Self::from_bits(0, 1);
pub const METADATA_TOPIC_ID: Self = Self::ONE;
pub const RESERVED: [Self; 2] = [Self::ZERO, Self::ONE];
pub const DIRECTORY_MIGRATING: Self = Self::ZERO;
pub const DIRECTORY_UNASSIGNED: Self = Self::ONE;
pub const DIRECTORY_LOST: Self = Self::from_bits(0, 2);
#[must_use]
pub const fn from_bits(most_significant: i64, least_significant: i64) -> Self {
Self(Uuid::from_u64_pair(
most_significant.cast_unsigned(),
least_significant.cast_unsigned(),
))
}
#[must_use]
pub const fn get(self) -> Uuid {
self.0
}
#[must_use]
pub const fn most_significant_bits(self) -> i64 {
self.0.as_u64_pair().0.cast_signed()
}
#[must_use]
pub const fn least_significant_bits(self) -> i64 {
self.0.as_u64_pair().1.cast_signed()
}
#[must_use]
pub fn random() -> Self {
first_accepted(Uuid::new_v4, Self::is_rejected_random)
}
#[must_use]
pub fn random_directory_id() -> Self {
first_accepted(Uuid::new_v4, Self::is_rejected_directory_id)
}
#[must_use]
pub fn is_reserved(self) -> bool {
Self::RESERVED.contains(&self)
}
#[must_use]
pub const fn is_reserved_directory_id(self) -> bool {
self.most_significant_bits() == 0 && self.least_significant_bits() < 100
}
pub fn parse_kafka_or_hyphenated(input: &str) -> Result<Self, KafkaUuidError> {
if has_hyphenated_shape(input) {
return Uuid::try_parse(input)
.map(Self)
.map_err(|source| KafkaUuidError::Hyphenated {
input: input.to_owned(),
source,
});
}
input.parse()
}
fn is_rejected_random(self) -> bool {
self.is_reserved() || self.string_starts_with_dash()
}
fn is_rejected_directory_id(self) -> bool {
self.is_rejected_random() || self.is_reserved_directory_id()
}
fn string_starts_with_dash(self) -> bool {
self.0.as_bytes()[0] >> 2 == 62
}
}
fn first_accepted(mut next: impl FnMut() -> Uuid, rejected: fn(KafkaUuid) -> bool) -> KafkaUuid {
loop {
let candidate = KafkaUuid(next());
if !rejected(candidate) {
return candidate;
}
}
}
fn has_hyphenated_shape(input: &str) -> bool {
let bytes = input.as_bytes();
bytes.len() == 36 && [8, 13, 18, 23].iter().all(|&i| bytes[i] == b'-')
}
impl fmt::Display for KafkaUuid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(&URL_SAFE_NO_PAD.encode(self.0.as_bytes()))
}
}
impl FromStr for KafkaUuid {
type Err = KafkaUuidError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
if input.encode_utf16().count() > MAX_ENCODED_LEN {
return Err(KafkaUuidError::TooLong {
prefix: java_prefix(input, MAX_ENCODED_LEN),
});
}
let decoded = java_url_decode(&latin1_bytes(input))?;
let bytes: [u8; UUID_LEN] =
decoded
.try_into()
.map_err(|decoded: Vec<u8>| KafkaUuidError::WrongLength {
input: input.to_owned(),
decoded_len: decoded.len(),
})?;
Ok(Self(Uuid::from_bytes(bytes)))
}
}
impl PartialOrd for KafkaUuid {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for KafkaUuid {
fn cmp(&self, other: &Self) -> Ordering {
(self.most_significant_bits(), self.least_significant_bits()).cmp(&(
other.most_significant_bits(),
other.least_significant_bits(),
))
}
}
impl Serialize for KafkaUuid {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for KafkaUuid {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer)?
.parse()
.map_err(D::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum KafkaUuidError {
#[error("Input string with prefix `{prefix}` is too long to be decoded as a base64 UUID")]
TooLong {
prefix: String,
},
#[error(
"Input string `{input}` decoded as {decoded_len} bytes, which is not equal to the \
expected 16 bytes of a base64-encoded UUID"
)]
WrongLength {
input: String,
decoded_len: usize,
},
#[error("Input byte[] should at least have 2 bytes for base64 bytes")]
TooShort,
#[error("Illegal base64 character {}", JavaSignedHex(*.byte))]
IllegalCharacter {
byte: u8,
},
#[error("Input byte array has wrong 4-byte ending unit")]
WrongEndingUnit,
#[error("Last unit does not have enough valid bits")]
LastUnitTooShort,
#[error("Input byte array has incorrect ending byte at {position}")]
IncorrectEndingByte {
position: usize,
},
#[error("Input string `{input}` is not a valid hyphenated UUID: {source}")]
Hyphenated {
input: String,
source: uuid::Error,
},
}
struct JavaSignedHex(u8);
impl fmt::Display for JavaSignedHex {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let value = self.0.cast_signed();
if value < 0 {
write!(f, "-{:x}", value.unsigned_abs())
} else {
write!(f, "{value:x}")
}
}
}
fn java_prefix(input: &str, max: usize) -> String {
let mut prefix = String::new();
let mut units = 0;
for c in input.chars() {
let width = c.len_utf16();
if units + width > max {
if units < max {
prefix.push('?');
}
break;
}
prefix.push(c);
units += width;
}
prefix
}
fn latin1_bytes(input: &str) -> Vec<u8> {
input
.chars()
.map(|c| u8::try_from(c).unwrap_or(b'?'))
.collect()
}
fn url_value(byte: u8) -> Option<u32> {
let value = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a' + 26,
b'0'..=b'9' => byte - b'0' + 52,
b'-' => 62,
b'_' => 63,
_ => return None,
};
Some(u32::from(value))
}
fn java_url_decode(src: &[u8]) -> Result<Vec<u8>, KafkaUuidError> {
if src.len() == 1 {
return Err(KafkaUuidError::TooShort);
}
let mut dst = Vec::with_capacity(src.len() / 4 * 3 + 2);
let mut bits = 0u32;
let mut shift_to = 18i32;
let mut sp = 0;
while sp < src.len() {
let byte = src[sp];
sp += 1;
if let Some(value) = url_value(byte) {
bits |= value << shift_to;
shift_to -= 6;
if shift_to < 0 {
dst.extend_from_slice(&bits.to_be_bytes()[1..]);
shift_to = 18;
bits = 0;
}
continue;
}
if byte != b'=' {
return Err(KafkaUuidError::IllegalCharacter { byte });
}
let second_pad_missing = shift_to == 6
&& (sp == src.len() || {
sp += 1;
src[sp - 1] != b'='
});
if second_pad_missing || shift_to == 18 {
return Err(KafkaUuidError::WrongEndingUnit);
}
break;
}
match shift_to {
6 => dst.push(bits.to_be_bytes()[1]),
0 => dst.extend_from_slice(&bits.to_be_bytes()[1..3]),
12 => return Err(KafkaUuidError::LastUnitTooShort),
_ => {}
}
if sp < src.len() {
return Err(KafkaUuidError::IncorrectEndingByte { position: sp });
}
Ok(dst)
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use uuid::Uuid;
use super::{KafkaUuid, first_accepted};
type Rejected = fn(KafkaUuid) -> bool;
#[test]
fn random_generators_skip_the_ids_that_kafka_skips() {
let dash = Uuid::from_u128(0xf800_0000_0000_4000_8000_0000_0000_0000);
let dash_last = Uuid::from_u128(0xfbff_ffff_ffff_4fff_bfff_ffff_ffff_ffff);
let not_dash = Uuid::from_u128(0xfc00_0000_0000_4000_8000_0000_0000_0000);
let reserved_dir = Uuid::from_u128(99);
let first_free_dir = Uuid::from_u128(100);
let cases: [(&str, Vec<Uuid>, Rejected, Uuid); 5] = [
(
"random skips ZERO and ONE",
vec![Uuid::nil(), Uuid::from_u128(1), reserved_dir],
KafkaUuid::is_rejected_random,
reserved_dir,
),
(
"random skips a leading dash",
vec![dash, dash_last, not_dash],
KafkaUuid::is_rejected_random,
not_dash,
),
(
"directory id skips the random rejects",
vec![Uuid::nil(), dash, first_free_dir],
KafkaUuid::is_rejected_directory_id,
first_free_dir,
),
(
"directory id skips the first 100",
vec![Uuid::from_u128(2), reserved_dir, first_free_dir],
KafkaUuid::is_rejected_directory_id,
first_free_dir,
),
(
"directory id takes the msb-nonzero id",
vec![Uuid::from_u64_pair(1, 0)],
KafkaUuid::is_rejected_directory_id,
Uuid::from_u64_pair(1, 0),
),
];
for (name, candidates, rejected, expected) in cases {
let mut queue = VecDeque::from(candidates);
let chosen = first_accepted(|| queue.pop_front().expect("ran out"), rejected);
assert2::assert!(chosen == KafkaUuid(expected), "{name}");
}
}
#[test]
fn the_dash_check_agrees_with_the_string_form() {
for first_byte in 0..=u8::MAX {
let mut bytes = [0u8; 16];
bytes[0] = first_byte;
let id = KafkaUuid(Uuid::from_bytes(bytes));
assert2::assert!(
id.string_starts_with_dash() == id.to_string().starts_with('-'),
"{first_byte:#x}"
);
}
}
}