1use core::{cmp::Ordering, fmt, str::FromStr};
4
5use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
6use derive_more::{From, Into};
7use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
8use thiserror::Error;
9use uuid::Uuid;
10
11const MAX_ENCODED_LEN: usize = 24;
13
14const UUID_LEN: usize = 16;
16
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, From, Into)]
53pub struct KafkaUuid(pub Uuid);
54
55impl KafkaUuid {
56 pub const ZERO: Self = Self::from_bits(0, 0);
58
59 pub const ONE: Self = Self::from_bits(0, 1);
61
62 pub const METADATA_TOPIC_ID: Self = Self::ONE;
64
65 pub const RESERVED: [Self; 2] = [Self::ZERO, Self::ONE];
68
69 pub const DIRECTORY_MIGRATING: Self = Self::ZERO;
72
73 pub const DIRECTORY_UNASSIGNED: Self = Self::ONE;
76
77 pub const DIRECTORY_LOST: Self = Self::from_bits(0, 2);
80
81 #[must_use]
83 pub const fn from_bits(most_significant: i64, least_significant: i64) -> Self {
84 Self(Uuid::from_u64_pair(
85 most_significant.cast_unsigned(),
86 least_significant.cast_unsigned(),
87 ))
88 }
89
90 #[must_use]
92 pub const fn get(self) -> Uuid {
93 self.0
94 }
95
96 #[must_use]
99 pub const fn most_significant_bits(self) -> i64 {
100 self.0.as_u64_pair().0.cast_signed()
101 }
102
103 #[must_use]
106 pub const fn least_significant_bits(self) -> i64 {
107 self.0.as_u64_pair().1.cast_signed()
108 }
109
110 #[must_use]
116 pub fn random() -> Self {
117 first_accepted(Uuid::new_v4, Self::is_rejected_random)
118 }
119
120 #[must_use]
127 pub fn random_directory_id() -> Self {
128 first_accepted(Uuid::new_v4, Self::is_rejected_directory_id)
129 }
130
131 #[must_use]
133 pub fn is_reserved(self) -> bool {
134 Self::RESERVED.contains(&self)
135 }
136
137 #[must_use]
140 pub const fn is_reserved_directory_id(self) -> bool {
141 self.most_significant_bits() == 0 && self.least_significant_bits() < 100
142 }
143
144 pub fn parse_kafka_or_hyphenated(input: &str) -> Result<Self, KafkaUuidError> {
158 if has_hyphenated_shape(input) {
159 return Uuid::try_parse(input)
160 .map(Self)
161 .map_err(|source| KafkaUuidError::Hyphenated {
162 input: input.to_owned(),
163 source,
164 });
165 }
166 input.parse()
167 }
168
169 fn is_rejected_random(self) -> bool {
170 self.is_reserved() || self.string_starts_with_dash()
171 }
172
173 fn is_rejected_directory_id(self) -> bool {
174 self.is_rejected_random() || self.is_reserved_directory_id()
175 }
176
177 fn string_starts_with_dash(self) -> bool {
180 self.0.as_bytes()[0] >> 2 == 62
181 }
182}
183
184fn first_accepted(mut next: impl FnMut() -> Uuid, rejected: fn(KafkaUuid) -> bool) -> KafkaUuid {
186 loop {
187 let candidate = KafkaUuid(next());
188 if !rejected(candidate) {
189 return candidate;
190 }
191 }
192}
193
194fn has_hyphenated_shape(input: &str) -> bool {
195 let bytes = input.as_bytes();
196 bytes.len() == 36 && [8, 13, 18, 23].iter().all(|&i| bytes.get(i) == Some(&b'-'))
197}
198
199impl fmt::Display for KafkaUuid {
200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201 f.pad(&URL_SAFE_NO_PAD.encode(self.0.as_bytes()))
202 }
203}
204
205impl FromStr for KafkaUuid {
206 type Err = KafkaUuidError;
207
208 fn from_str(input: &str) -> Result<Self, Self::Err> {
210 if input.encode_utf16().count() > MAX_ENCODED_LEN {
212 return Err(KafkaUuidError::TooLong {
213 prefix: java_prefix(input, MAX_ENCODED_LEN),
214 });
215 }
216 let decoded = java_url_decode(&latin1_bytes(input))?;
217 let bytes: [u8; UUID_LEN] =
218 decoded
219 .try_into()
220 .map_err(|decoded: Vec<u8>| KafkaUuidError::WrongLength {
221 input: input.to_owned(),
222 decoded_len: decoded.len(),
223 })?;
224 Ok(Self(Uuid::from_bytes(bytes)))
225 }
226}
227
228impl PartialOrd for KafkaUuid {
229 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
230 Some(self.cmp(other))
231 }
232}
233
234impl Ord for KafkaUuid {
235 fn cmp(&self, other: &Self) -> Ordering {
236 (self.most_significant_bits(), self.least_significant_bits()).cmp(&(
237 other.most_significant_bits(),
238 other.least_significant_bits(),
239 ))
240 }
241}
242
243impl Serialize for KafkaUuid {
244 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
245 serializer.collect_str(self)
246 }
247}
248
249impl<'de> Deserialize<'de> for KafkaUuid {
250 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
251 String::deserialize(deserializer)?
252 .parse()
253 .map_err(D::Error::custom)
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Error)]
264#[non_exhaustive]
265pub enum KafkaUuidError {
266 #[error("Input string with prefix `{prefix}` is too long to be decoded as a base64 UUID")]
268 TooLong {
269 prefix: String,
271 },
272
273 #[error(
275 "Input string `{input}` decoded as {decoded_len} bytes, which is not equal to the \
276 expected 16 bytes of a base64-encoded UUID"
277 )]
278 WrongLength {
279 input: String,
281 decoded_len: usize,
283 },
284
285 #[error("Input byte[] should at least have 2 bytes for base64 bytes")]
287 TooShort,
288
289 #[error("Illegal base64 character {}", JavaSignedHex(*.byte))]
291 IllegalCharacter {
292 byte: u8,
294 },
295
296 #[error("Input byte array has wrong 4-byte ending unit")]
298 WrongEndingUnit,
299
300 #[error("Last unit does not have enough valid bits")]
302 LastUnitTooShort,
303
304 #[error("Input byte array has incorrect ending byte at {position}")]
306 IncorrectEndingByte {
307 position: usize,
309 },
310
311 #[error("Input string `{input}` is not a valid hyphenated UUID: {source}")]
314 Hyphenated {
315 input: String,
317 source: uuid::Error,
319 },
320}
321
322struct JavaSignedHex(u8);
325
326impl fmt::Display for JavaSignedHex {
327 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
328 let value = self.0.cast_signed();
329 if value < 0 {
330 write!(f, "-{:x}", value.unsigned_abs())
331 } else {
332 write!(f, "{value:x}")
333 }
334 }
335}
336
337fn java_prefix(input: &str, max: usize) -> String {
343 let mut prefix = String::new();
344 let mut units = 0;
345 for c in input.chars() {
346 let width = c.len_utf16();
347 if units + width > max {
348 if units < max {
349 prefix.push('?');
350 }
351 break;
352 }
353 prefix.push(c);
354 units += width;
355 }
356 prefix
357}
358
359fn latin1_bytes(input: &str) -> Vec<u8> {
362 input
363 .chars()
364 .map(|c| u8::try_from(c).unwrap_or(b'?'))
365 .collect()
366}
367
368fn url_value(byte: u8) -> Option<u32> {
370 let value = match byte {
371 b'A'..=b'Z' => byte - b'A',
372 b'a'..=b'z' => byte - b'a' + 26,
373 b'0'..=b'9' => byte - b'0' + 52,
374 b'-' => 62,
375 b'_' => 63,
376 _ => return None,
377 };
378 Some(u32::from(value))
379}
380
381fn java_url_decode(src: &[u8]) -> Result<Vec<u8>, KafkaUuidError> {
389 if src.len() == 1 {
391 return Err(KafkaUuidError::TooShort);
392 }
393
394 let mut dst = Vec::with_capacity(src.len() / 4 * 3 + 2);
396 let mut bits = 0u32;
397 let mut shift_to = 18i32;
398 let mut sp = 0;
399 while let Some(&byte) = src.get(sp) {
400 sp += 1;
401 if let Some(value) = url_value(byte) {
402 bits |= value << shift_to;
403 shift_to -= 6;
404 if shift_to < 0 {
405 dst.extend_from_slice(&bits.to_be_bytes()[1..]);
406 shift_to = 18;
407 bits = 0;
408 }
409 continue;
410 }
411 if byte != b'=' {
412 return Err(KafkaUuidError::IllegalCharacter { byte });
413 }
414 let second_pad_missing = shift_to == 6
416 && src.get(sp).is_none_or(|&next| {
417 sp += 1;
418 next != b'='
419 });
420 if second_pad_missing || shift_to == 18 {
421 return Err(KafkaUuidError::WrongEndingUnit);
422 }
423 break;
424 }
425 match shift_to {
426 6 => dst.push(bits.to_be_bytes()[1]),
427 0 => dst.extend_from_slice(&bits.to_be_bytes()[1..3]),
428 12 => return Err(KafkaUuidError::LastUnitTooShort),
429 _ => {}
430 }
431 if sp < src.len() {
432 return Err(KafkaUuidError::IncorrectEndingByte { position: sp });
433 }
434 Ok(dst)
435}
436
437#[cfg(test)]
438mod tests {
439 use std::collections::VecDeque;
440
441 use uuid::Uuid;
442
443 use super::{KafkaUuid, first_accepted};
444
445 type Rejected = fn(KafkaUuid) -> bool;
446
447 #[test]
451 fn random_generators_skip_the_ids_that_kafka_skips() {
452 let dash = Uuid::from_u128(0xf800_0000_0000_4000_8000_0000_0000_0000);
453 let dash_last = Uuid::from_u128(0xfbff_ffff_ffff_4fff_bfff_ffff_ffff_ffff);
454 let not_dash = Uuid::from_u128(0xfc00_0000_0000_4000_8000_0000_0000_0000);
455 let reserved_dir = Uuid::from_u128(99);
456 let first_free_dir = Uuid::from_u128(100);
457
458 let cases: [(&str, Vec<Uuid>, Rejected, Uuid); 5] = [
459 (
460 "random skips ZERO and ONE",
461 vec![Uuid::nil(), Uuid::from_u128(1), reserved_dir],
462 KafkaUuid::is_rejected_random,
463 reserved_dir,
464 ),
465 (
466 "random skips a leading dash",
467 vec![dash, dash_last, not_dash],
468 KafkaUuid::is_rejected_random,
469 not_dash,
470 ),
471 (
472 "directory id skips the random rejects",
473 vec![Uuid::nil(), dash, first_free_dir],
474 KafkaUuid::is_rejected_directory_id,
475 first_free_dir,
476 ),
477 (
478 "directory id skips the first 100",
479 vec![Uuid::from_u128(2), reserved_dir, first_free_dir],
480 KafkaUuid::is_rejected_directory_id,
481 first_free_dir,
482 ),
483 (
484 "directory id takes the msb-nonzero id",
485 vec![Uuid::from_u64_pair(1, 0)],
486 KafkaUuid::is_rejected_directory_id,
487 Uuid::from_u64_pair(1, 0),
488 ),
489 ];
490 for (name, candidates, rejected, expected) in cases {
491 let mut queue = VecDeque::from(candidates);
492 let chosen = first_accepted(|| queue.pop_front().expect("ran out"), rejected);
493 assert2::assert!(chosen == KafkaUuid(expected), "{name}");
494 }
495 }
496
497 #[test]
498 fn the_dash_check_agrees_with_the_string_form() {
499 for first_byte in 0..=u8::MAX {
500 let mut bytes = [0u8; 16];
501 bytes[0] = first_byte;
502 let id = KafkaUuid(Uuid::from_bytes(bytes));
503 assert2::assert!(
504 id.string_starts_with_dash() == id.to_string().starts_with('-'),
505 "{first_byte:#x}"
506 );
507 }
508 }
509}