1use thiserror::Error;
8
9pub const MAX_TOPIC_NAME_LENGTH: usize = 249;
11
12#[derive(Debug, Clone, PartialEq, Eq, Error)]
14pub enum InvalidTopicName {
15 #[error("Topic name is invalid: the empty string is not allowed")]
17 Empty,
18 #[error("Topic name is invalid: '.' is not allowed")]
20 Dot,
21 #[error("Topic name is invalid: '..' is not allowed")]
23 DotDot,
24 #[error(
26 "Topic name is invalid: the length of '{0}' is longer than the max allowed length {MAX_TOPIC_NAME_LENGTH}"
27 )]
28 TooLong(String),
29 #[error(
31 "Topic name is invalid: '{0}' contains one or more characters other than ASCII alphanumerics, '.', '_' and '-'"
32 )]
33 IllegalCharacter(String),
34}
35
36pub fn validate_topic_name(name: &str) -> Result<(), InvalidTopicName> {
42 if name.is_empty() {
43 return Err(InvalidTopicName::Empty);
44 }
45 if name == "." {
46 return Err(InvalidTopicName::Dot);
47 }
48 if name == ".." {
49 return Err(InvalidTopicName::DotDot);
50 }
51 if name.len() > MAX_TOPIC_NAME_LENGTH && name.encode_utf16().count() > MAX_TOPIC_NAME_LENGTH {
55 return Err(InvalidTopicName::TooLong(name.to_owned()));
56 }
57 if name.bytes().fold(1, |legal, byte| {
60 legal & LEGAL_BYTES.get(usize::from(byte)).copied().unwrap_or(0)
62 }) == 0
63 {
64 return Err(InvalidTopicName::IllegalCharacter(name.to_owned()));
65 }
66 Ok(())
67}
68
69const LEGAL_BYTES: [u8; 256] = {
72 let mut table = [0; 256];
73 let mut rest: &mut [u8] = &mut table;
74 let mut byte = 0;
75 while let [slot, tail @ ..] = rest {
76 if matches!(byte, 0x30..=0x39 | 0x41..=0x5A | 0x61..=0x7A | 0x2E | 0x5F | 0x2D) {
79 *slot = 1;
80 }
81 rest = tail;
82 byte += 1;
83 }
84 table
85};
86
87#[must_use]
91pub fn topic_names_collide(a: &str, b: &str) -> bool {
92 a.len() == b.len()
93 && a.bytes()
94 .zip(b.bytes())
95 .all(|(x, y)| unify_collision_byte(x) == unify_collision_byte(y))
96}
97
98fn unify_collision_byte(byte: u8) -> u8 { if byte == b'.' { b'_' } else { byte } }
99
100#[cfg(test)]
101mod tests {
102 use {super::*, assert2::check, std::collections::BTreeSet};
103
104 #[yare::parameterized(
105 ordinary = { "orders".to_owned(), Ok(()) },
106 allowed_punctuation = { "a.b_c-D9".to_owned(), Ok(()) },
107 max_utf16_length = { "a".repeat(MAX_TOPIC_NAME_LENGTH), Ok(()) },
108 empty = { String::new(), Err(InvalidTopicName::Empty) },
109 dot = { ".".to_owned(), Err(InvalidTopicName::Dot) },
110 dot_dot = { "..".to_owned(), Err(InvalidTopicName::DotDot) },
111 multiple_dots = { "...".to_owned(), Ok(()) },
112 too_long = {
113 "a".repeat(MAX_TOPIC_NAME_LENGTH + 1),
114 Err(InvalidTopicName::TooLong("a".repeat(MAX_TOPIC_NAME_LENGTH + 1))),
115 },
116 slash = { "a/b".to_owned(), Err(InvalidTopicName::IllegalCharacter("a/b".to_owned())) },
117 parent_path = {
118 "../x".to_owned(),
119 Err(InvalidTopicName::IllegalCharacter("../x".to_owned())),
120 },
121 absolute_path = {
122 "/tmp/x".to_owned(),
123 Err(InvalidTopicName::IllegalCharacter("/tmp/x".to_owned())),
124 },
125 space = { "a b".to_owned(), Err(InvalidTopicName::IllegalCharacter("a b".to_owned())) },
126 non_ascii = { "é".repeat(200), Err(InvalidTopicName::IllegalCharacter("é".repeat(200))) },
127 too_long_before_illegal = {
128 "a/".repeat(125),
129 Err(InvalidTopicName::TooLong("a/".repeat(125))),
130 },
131 astral_utf16_too_long = {
132 "\u{1F600}".repeat(125),
133 Err(InvalidTopicName::TooLong("\u{1F600}".repeat(125))),
134 },
135 astral_utf16_at_limit = {
136 format!("a{}", "\u{1F600}".repeat(124)),
137 Err(InvalidTopicName::IllegalCharacter(format!("a{}", "\u{1F600}".repeat(124)))),
138 },
139 )]
140 fn validate_topic_name_matches_kafka(name: String, expected: Result<(), InvalidTopicName>) {
141 check!(validate_topic_name(&name) == expected, "{name:?}");
142 }
143
144 #[yare::parameterized(
145 empty = {
146 InvalidTopicName::Empty,
147 "Topic name is invalid: the empty string is not allowed",
148 },
149 dot = { InvalidTopicName::Dot, "Topic name is invalid: '.' is not allowed" },
150 dot_dot = { InvalidTopicName::DotDot, "Topic name is invalid: '..' is not allowed" },
151 too_long = {
152 InvalidTopicName::TooLong("x".to_owned()),
153 "Topic name is invalid: the length of 'x' is longer than the max allowed length 249",
154 },
155 illegal_character = {
156 InvalidTopicName::IllegalCharacter("a/b".to_owned()),
157 "Topic name is invalid: 'a/b' contains one or more characters other than ASCII alphanumerics, '.', '_' and '-'",
158 },
159 )]
160 fn invalid_topic_name_messages_are_kafka_text(error: InvalidTopicName, message: &str) {
161 check!(error.to_string() == message);
162 }
163
164 #[yare::parameterized(
165 dot_to_underscore = { "a.b", "a_b", true },
166 underscore_to_dot = { "a_b", "a.b", true },
167 equal_dot = { "a.b", "a.b", true },
168 hyphen_differs = { "a.b", "a-b", false },
169 longer_name_differs = { "a.b", "a.bc", false },
170 distinct_names = { "orders", "orders2", false },
171 )]
172 fn topic_names_collide_unifies_dot_and_underscore(a: &str, b: &str, expected: bool) {
173 check!(topic_names_collide(a, b) == expected, "{a} {b}");
174 }
175
176 #[test]
179 fn every_byte_is_legal_exactly_when_kafka_allows_it() {
180 let expected: BTreeSet<u8> = (b'a'..=b'z')
181 .chain(b'A'..=b'Z')
182 .chain(b'0'..=b'9')
183 .chain(*b"._-")
184 .collect();
185 let actual: BTreeSet<u8> = (0..=u8::MAX)
186 .filter(|&byte| LEGAL_BYTES[usize::from(byte)] == 1)
187 .collect();
188 check!(actual == expected);
189 }
190}