Skip to main content

krabka_log/
topic_name.rs

1//! The topic-name rules of Apache Kafka's `org.apache.kafka.common.internals.Topic`.
2//!
3//! A topic name becomes part of a filesystem path: the partition directory is `<log_dir>/<topic>-<partition>`. A name that holds a `/`, or that is `.` or `..`, gives a path outside the log directory. [`validate_topic_name`] is the one check. Every path that creates a topic, and every path that creates or removes a partition directory from a name, calls it. A name that passes gives a partition directory that is a direct child of the log directory.
4//!
5//! The error text is Kafka's text, because `CreateTopics` sends it to the client as the error message.
6
7use thiserror::Error;
8
9/// Longest topic name Kafka accepts, in UTF-16 code units (`Topic.MAX_NAME_LENGTH`).
10pub const MAX_TOPIC_NAME_LENGTH: usize = 249;
11
12/// Why a topic name is invalid. The display text is the message of Kafka's `InvalidTopicException` from `Topic.validate`.
13#[derive(Debug, Clone, PartialEq, Eq, Error)]
14pub enum InvalidTopicName {
15    /// The name is the empty string.
16    #[error("Topic name is invalid: the empty string is not allowed")]
17    Empty,
18    /// The name is `.`.
19    #[error("Topic name is invalid: '.' is not allowed")]
20    Dot,
21    /// The name is `..`.
22    #[error("Topic name is invalid: '..' is not allowed")]
23    DotDot,
24    /// The name is longer than [`MAX_TOPIC_NAME_LENGTH`].
25    #[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    /// The name holds a character other than ASCII alphanumerics, `.`, `_` and `-`.
30    #[error(
31        "Topic name is invalid: '{0}' contains one or more characters other than ASCII alphanumerics, '.', '_' and '-'"
32    )]
33    IllegalCharacter(String),
34}
35
36/// Checks `name` against Kafka's `Topic.validate`, in Kafka's order.
37///
38/// # Errors
39///
40/// Returns the first [`InvalidTopicName`] rule that `name` breaks.
41pub 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    // Java's `String.length()` counts UTF-16 code units. A string holds no
52    // more UTF-16 code units than UTF-8 bytes, so a name within the limit in
53    // bytes is within it in code units, and only a longer one is counted.
54    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    // A fold with no early exit and a table lookup per byte lets the
58    // compiler check many bytes at once.
59    if name.bytes().fold(1, |legal, byte| {
60        // Every `u8` indexes the 256-entry table.
61        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
69/// `1` for each byte Kafka's `Topic.containsValidPattern` accepts (an ASCII
70/// alphanumeric, `.`, `_` or `-`) and `0` for every other byte.
71const 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        // The byte values of `0-9`, `A-Z`, `a-z`, `.`, `_` and `-`: the
77        // index is a `usize`, which a byte literal is not.
78        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/// Whether `a` and `b` are the same name after `.` becomes `_` (Kafka's `Topic.hasCollision`).
88///
89/// Kafka metric names cannot tell `.` from `_`, so Kafka refuses to create a topic that collides with an existing topic.
90#[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    /// The lookup table accepts exactly the bytes Kafka's legal pattern
177    /// `[a-zA-Z0-9._-]` accepts.
178    #[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}