1use {
31 base64::{
32 Engine as _,
33 alphabet::URL_SAFE,
34 engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
35 },
36 derive_more::{From, Into},
37 thiserror::Error,
38 uuid::Uuid,
39};
40
41const MAX_ENCODED_LEN: usize = 24;
43
44const KAFKA_BASE64: GeneralPurpose = GeneralPurpose::new(
47 &URL_SAFE,
48 GeneralPurposeConfig::new()
49 .with_encode_padding(false)
50 .with_decode_allow_trailing_bits(true)
51 .with_decode_padding_mode(DecodePaddingMode::RequireNone),
52);
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, From, Into, krabka_macros::KafkaUuid)]
58pub struct ClusterId(pub Uuid);
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, From, Into, krabka_macros::KafkaUuid)]
67pub struct DirectoryId(pub Uuid);
68
69impl ClusterId {
70 #[must_use]
76 pub fn random() -> Self { Self(random_uuid()) }
77
78 pub fn parse_cli(input: &str) -> Result<Self, KafkaUuidError> {
86 parse_kafka_or_hyphenated(input).map(Self)
87 }
88}
89
90impl DirectoryId {
91 #[must_use]
97 pub fn random() -> Self {
98 Self(first_accepted(|id| {
99 is_reserved_directory_id(id) || starts_with_dash(id)
100 }))
101 }
102
103 #[must_use]
107 pub fn random_unused(used: &[Self]) -> Self {
108 loop {
109 let id = Self::random();
110 if !used.contains(&id) {
111 return id;
112 }
113 }
114 }
115
116 #[must_use]
119 pub fn is_reserved(self) -> bool { is_reserved_directory_id(self.0) }
120
121 pub fn parse_cli(input: &str) -> Result<Self, KafkaUuidError> {
127 parse_kafka_or_hyphenated(input).map(Self)
128 }
129}
130
131#[must_use]
138pub fn random_uuid() -> Uuid { first_accepted(|id| is_reserved(id) || starts_with_dash(id)) }
139
140fn is_reserved(id: Uuid) -> bool { id.as_u128() <= 1 }
142
143fn is_reserved_directory_id(id: Uuid) -> bool {
146 let (most, least) = id.as_u64_pair();
147 most == 0 && least.cast_signed() < 100
148}
149
150fn starts_with_dash(id: Uuid) -> bool { id.as_bytes()[0] >> 2 == 62 }
153
154fn first_accepted(rejected: impl Fn(Uuid) -> bool) -> Uuid {
156 loop {
157 let candidate = Uuid::new_v4();
158 if !rejected(candidate) {
159 return candidate;
160 }
161 }
162}
163
164fn encode(id: Uuid) -> String { KAFKA_BASE64.encode(id.as_bytes()) }
166
167fn decode(input: &str) -> Result<Uuid, KafkaUuidError> {
173 if input.encode_utf16().count() > MAX_ENCODED_LEN {
174 return Err(KafkaUuidError::TooLong {
175 prefix: java_prefix(input, MAX_ENCODED_LEN),
176 });
177 }
178 let unpadded = input.strip_suffix("==").unwrap_or(input);
179 let bytes = KAFKA_BASE64
180 .decode(unpadded)
181 .map_err(|error| KafkaUuidError::NotBase64 {
182 input: input.to_owned(),
183 reason: error.to_string(),
184 })?;
185 let Ok(uuid) = <[u8; 16]>::try_from(bytes.as_slice()) else {
186 return Err(KafkaUuidError::WrongLength {
187 input: input.to_owned(),
188 decoded_len: bytes.len(),
189 });
190 };
191 Ok(Uuid::from_bytes(uuid))
192}
193
194fn parse_kafka_or_hyphenated(input: &str) -> Result<Uuid, KafkaUuidError> {
197 let bytes = input.as_bytes();
198 if bytes.len() == 36 && [8, 13, 18, 23].iter().all(|&i| bytes.get(i) == Some(&b'-')) {
199 return Uuid::try_parse(input).map_err(|error| KafkaUuidError::Hyphenated {
200 input: input.to_owned(),
201 reason: error.to_string(),
202 });
203 }
204 decode(input)
205}
206
207fn java_prefix(input: &str, max: usize) -> String {
211 let mut prefix = String::new();
212 let mut units = 0;
213 for c in input.chars() {
214 let width = c.len_utf16();
215 if units + width > max {
216 if units < max {
217 prefix.push('?');
218 }
219 break;
220 }
221 prefix.push(c);
222 units += width;
223 }
224 prefix
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Error)]
234#[non_exhaustive]
235pub enum KafkaUuidError {
236 #[error("Input string with prefix `{prefix}` is too long to be decoded as a base64 UUID")]
238 TooLong {
239 prefix: String,
241 },
242
243 #[error(
245 "Input string `{input}` decoded as {decoded_len} bytes, which is not equal to the \
246 expected 16 bytes of a base64-encoded UUID"
247 )]
248 WrongLength {
249 input: String,
251 decoded_len: usize,
253 },
254
255 #[error("Input string `{input}` is not base64url: {reason}")]
257 NotBase64 {
258 input: String,
260 reason: String,
262 },
263
264 #[error("Input string `{input}` is not a valid hyphenated UUID: {reason}")]
267 Hyphenated {
268 input: String,
270 reason: String,
272 },
273}
274
275#[cfg(test)]
276mod tests {
277 use {super::*, assert2::check};
278
279 #[yare::parameterized(
283 kafka_cluster = { "AQIDBAUGBwgJCgsMDQ4PEA", Ok("AQIDBAUGBwgJCgsMDQ4PEA") },
284 kafka_directory = { "U-hCdTzaSPyFB3swwc7Qdw", Ok("U-hCdTzaSPyFB3swwc7Qdw") },
285 reserved_zero = { "AAAAAAAAAAAAAAAAAAAAAA", Ok("AAAAAAAAAAAAAAAAAAAAAA") },
286 reserved_one = { "AAAAAAAAAAAAAAAAAAAAAQ", Ok("AAAAAAAAAAAAAAAAAAAAAQ") },
287 ignored_low_bits = { "_____________________x", Ok("_____________________w") },
288 rounded_low_bits = { "AAAAAAAAAAAAAAAAAAAAAB", Ok("AAAAAAAAAAAAAAAAAAAAAA") },
289 padded = { "AAAAAAAAAAAAAAAAAAAAAA==", Ok("AAAAAAAAAAAAAAAAAAAAAA") },
290 hyphenated_is_too_long = {
291 "01020304-0506-0708-090a-0b0c0d0e0f10",
292 Err(
293 "Input string with prefix `01020304-0506-0708-090a-` is too long to be decoded as a base64 UUID",
294 ),
295 },
296 unicode_too_long = {
297 "AAAAAAAAAAAAAAAAAAAAAAAš",
298 Err(
299 "Input string with prefix `AAAAAAAAAAAAAAAAAAAAAAA?` is too long to be decoded as a base64 UUID",
300 ),
301 },
302 twenty_four_chars = {
303 "AAAAAAAAAAAAAAAAAAAAAAAA",
304 Err(
305 "Input string `AAAAAAAAAAAAAAAAAAAAAAAA` decoded as 18 bytes, which is not equal to the expected 16 bytes of a base64-encoded UUID",
306 ),
307 },
308 empty = {
309 "",
310 Err(
311 "Input string `` decoded as 0 bytes, which is not equal to the expected 16 bytes of a base64-encoded UUID",
312 ),
313 },
314 short = {
315 "AAAAAA",
316 Err(
317 "Input string `AAAAAA` decoded as 4 bytes, which is not equal to the expected 16 bytes of a base64-encoded UUID",
318 ),
319 },
320 bad_padding_length = {
321 "AAAAAAAAAAAAAAAAAAAA==",
322 Err(
323 "Input string `AAAAAAAAAAAAAAAAAAAA==` decoded as 15 bytes, which is not equal to the expected 16 bytes of a base64-encoded UUID",
324 ),
325 },
326 twenty_one_chars = {
327 "AAAAAAAAAAAAAAAAAAAAA",
328 Err("Input string `AAAAAAAAAAAAAAAAAAAAA` is not base64url: Invalid input length: 21"),
329 },
330 invalid_plus = {
331 "AAAAAAAAAAAAAAAAAAAA+A",
332 Err("Input string `AAAAAAAAAAAAAAAAAAAA+A` is not base64url: Invalid symbol 43, offset 20."),
333 },
334 bad_padding = {
335 "AAAAAAAAAAAAAAAAAAAAAA=",
336 Err("Input string `AAAAAAAAAAAAAAAAAAAAAA=` is not base64url: Invalid padding"),
337 },
338 non_ascii = {
339 "AAAAAAAAAAAAAAAAAAAAAĆ©",
340 Err("Input string `AAAAAAAAAAAAAAAAAAAAAĆ©` is not base64url: Invalid symbol 195, offset 21."),
341 },
342 )]
343 fn parse_and_render_match_kafka_uuid(input: &str, want: Result<&str, &str>) {
344 let want = want.map(str::to_owned).map_err(str::to_owned);
345 let as_cluster = input
346 .parse::<ClusterId>()
347 .map(|id| id.to_string())
348 .map_err(|e| e.to_string());
349 assert2::check!(as_cluster == want, "{input:?}");
350 let as_directory = input
351 .parse::<DirectoryId>()
352 .map(|id| id.to_string())
353 .map_err(|e| e.to_string());
354 assert2::check!(as_directory == want, "{input:?} as a directory id");
355 }
356
357 #[yare::parameterized(
360 kafka_form = { "AQIDBAUGBwgJCgsMDQ4PEA" },
361 lowercase_hex = { "01020304-0506-0708-090a-0b0c0d0e0f10" },
362 uppercase_hex = { "01020304-0506-0708-090A-0B0C0D0E0F10" },
363 )]
364 fn the_command_line_accepts_both_forms(input: &str) {
365 let id = Uuid::from_u128(0x0102_0304_0506_0708_090a_0b0c_0d0e_0f10);
366 assert2::check!(ClusterId::parse_cli(input) == Ok(ClusterId(id)), "{input}");
367 assert2::check!(
368 DirectoryId::parse_cli(input) == Ok(DirectoryId(id)),
369 "{input}"
370 );
371 }
372
373 #[test]
374 fn the_command_line_refuses_malformed_hex_and_other_uuid_spellings() {
375 let not_hex = "01020304-0506-0708-090a-0b0c0d0e0f1g";
376 check!(matches!(
377 ClusterId::parse_cli(not_hex),
378 Err(KafkaUuidError::Hyphenated { input, .. }) if input == not_hex
379 ));
380 check!(matches!(
382 ClusterId::parse_cli("0102030405060708090a0b0c0d0e0f10"),
383 Err(KafkaUuidError::TooLong { .. })
384 ));
385 }
386
387 #[test]
390 fn serde_uses_the_kafka_string() {
391 let id = ClusterId(Uuid::from_u128(0x0102_0304_0506_0708_090a_0b0c_0d0e_0f10));
392 check!(serde_json::to_string(&id).unwrap() == r#""AQIDBAUGBwgJCgsMDQ4PEA""#);
393 check!(serde_json::from_str::<ClusterId>(r#""AQIDBAUGBwgJCgsMDQ4PEA""#).unwrap() == id);
394 check!(
395 serde_json::from_str::<DirectoryId>(r#""01020304-0506-0708-090a-0b0c0d0e0f10""#)
396 .is_err()
397 );
398 }
399
400 #[test]
402 fn random_ids_obey_kafka_generation_rules() {
403 for _ in 0..5000 {
406 let plain = random_uuid();
407 check!(!is_reserved(plain));
408 check!(!encode(plain).starts_with('-'));
409 }
410 for _ in 0..500 {
411 let cluster = ClusterId::random();
412 check!(!is_reserved(cluster.0));
413 check!(!cluster.to_string().starts_with('-'));
414 let directory = DirectoryId::random();
415 check!(!directory.is_reserved());
416 check!(!directory.to_string().starts_with('-'));
417 }
418 }
419
420 #[yare::parameterized(
422 zero = { Uuid::from_u64_pair(0, 0), true, true },
423 one = { Uuid::from_u64_pair(0, 1), true, true },
424 two = { Uuid::from_u64_pair(0, 2), false, true },
425 ninety_nine = { Uuid::from_u64_pair(0, 99), false, true },
426 one_hundred = { Uuid::from_u64_pair(0, 100), false, false },
427 low_bits_max = { Uuid::from_u64_pair(0, u64::MAX), false, true },
428 high_bits_one = { Uuid::from_u64_pair(1, 0), false, false },
429 high_and_low_one = { Uuid::from_u64_pair(1, 1), false, false },
430 )]
431 fn reservations_match_kafka(id: Uuid, reserved: bool, reserved_directory: bool) {
432 assert2::check!(
433 (is_reserved(id), is_reserved_directory_id(id)) == (reserved, reserved_directory),
434 "{id}"
435 );
436 }
437
438 #[test]
441 fn dash_check_matches_kafka_for_every_first_byte() {
442 for first_byte in 0..=u8::MAX {
443 let mut bytes = [0u8; 16];
444 bytes[0] = first_byte;
445 let id = Uuid::from_bytes(bytes);
446 check!(
447 starts_with_dash(id) == encode(id).starts_with('-'),
448 "{first_byte:#x}"
449 );
450 }
451 }
452}