mqute_codec/protocol/util.rs
1//! # MQTT Protocol Utilities
2//!
3//! This module provides utility functions for MQTT protocol handling, including:
4//! - Variable byte integer length calculation
5//! - Topic name and filter validation
6//! - System topic detection
7//!
8//! These utilities are used throughout the codec for validating MQTT-specific
9//! data structures and ensuring protocol compliance.
10//!
11
12/// Calculates the number of bytes required to encode a length value using MQTT's
13/// variable byte integer encoding format.
14///
15/// MQTT uses a variable-length encoding scheme for remaining length values where
16/// each byte encodes 7 bits of data with the most significant bit indicating
17/// continuation. This function determines how many bytes are needed to encode
18/// a given length value.
19///
20/// # Panics
21///
22/// Panics if the length exceeds the maximum allowed by MQTT specification
23/// (268,435,455 bytes or ~256 MB).
24///
25/// # MQTT Specification Reference
26///
27/// This implements the variable byte integer encoding from MQTT specification
28/// section 1.5.5.
29///
30/// # Example
31///
32/// ```
33/// use mqute_codec::protocol::util;
34///
35/// assert_eq!(util::len_bytes(127), 1); // Fits in 1 byte
36/// assert_eq!(util::len_bytes(128), 2); // Requires 2 bytes
37/// assert_eq!(util::len_bytes(16383), 2); // Maximum for 2 bytes
38/// assert_eq!(util::len_bytes(16384), 3); // Requires 3 bytes
39/// ```
40#[inline]
41pub fn len_bytes(len: usize) -> usize {
42 if len < 128 {
43 1
44 } else if len < 16_384 {
45 2
46 } else if len < 2_097_152 {
47 3
48 } else if len < 268_435_456 {
49 4
50 } else {
51 panic!("Length of remaining bytes must be less than 28 bits")
52 }
53}
54
55/// Validates whether a string is a valid MQTT topic name.
56///
57/// MQTT topic names must follow specific rules:
58/// - Must not be empty
59/// - Maximum length of 65,535 UTF-8 encoded bytes
60/// - Must not contain null characters
61/// - Must not contain wildcards (`+` or `#`)
62/// - Can contain any other UTF-8 characters including `/` for hierarchy
63///
64/// # MQTT Specification Reference
65///
66/// Follows MQTT specification rules for topic names (section 4.7).
67///
68/// # Example
69///
70/// ```
71/// use mqute_codec::protocol::util;
72///
73/// assert!(util::is_valid_topic_name("sensors/temperature"));
74/// assert!(util::is_valid_topic_name("$SYS/monitor"));
75/// assert!(!util::is_valid_topic_name("sensors/+")); // Contains wildcard
76/// assert!(!util::is_valid_topic_name("")); // Empty
77/// ```
78pub fn is_valid_topic_name<T: AsRef<str>>(name: T) -> bool {
79 let name = name.as_ref();
80
81 // Check minimum length and UTF-8 encoding length
82 if name.is_empty() || name.len() > 65_535 {
83 return false;
84 }
85
86 // Check for null character and wildcards (not allowed in topic names)
87 if name.contains('\0') || name.contains('#') || name.contains('+') {
88 return false;
89 }
90
91 true
92}
93
94/// Validates whether a string is a valid MQTT topic filter.
95///
96/// MQTT topic filters are used in subscriptions and can include wildcards:
97/// - `+` (single-level wildcard) - matches one hierarchy level
98/// - `#` (multi-level wildcard) - matches zero or more hierarchy levels
99///
100/// Validation rules:
101/// - Must not be empty
102/// - Maximum length of 65,535 UTF-8 encoded bytes
103/// - Must not contain null characters
104/// - Multi-level wildcard (`#`) must be the last character if present
105/// - Multi-level wildcard must be preceded by `/` unless it's the only character
106/// - Single-level wildcard (`+`) must occupy entire hierarchy levels
107///
108/// # MQTT Specification Reference
109///
110/// Follows MQTT specification rules for topic filters (section 4.7).
111///
112/// # Example
113///
114/// ```
115/// use mqute_codec::protocol::util;
116///
117/// assert!(util::is_valid_topic_filter("sensors/+/temperature"));
118/// assert!(util::is_valid_topic_filter("sensors/#"));
119/// assert!(util::is_valid_topic_filter("sensors/+/temperature/#"));
120/// assert!(!util::is_valid_topic_filter("sensors/temperature/#/ranking"));
121/// assert!(!util::is_valid_topic_filter("sensors+"));
122/// ```
123pub fn is_valid_topic_filter<T: AsRef<str>>(filter: T) -> bool {
124 let filter = filter.as_ref();
125
126 // Check minimum length and UTF-8 encoding length
127 if filter.is_empty() || filter.len() > 65_535 {
128 return false;
129 }
130
131 // Check for null character
132 if filter.contains('\0') {
133 return false;
134 }
135
136 // Multi-level wildcard validation
137 if let Some(pos) = filter.find('#') {
138 // Multi-level wildcard must be last character
139 if pos != filter.len() - 1 {
140 return false;
141 }
142
143 // Multi-level wildcard must be preceded by separator or be alone.
144 // `pos` is a *byte* offset (from `str::find`), so the preceding byte
145 // is checked directly instead of going through `chars().nth(...)`,
146 // which indexes by *character* and would both mis-check and panic
147 // (out-of-bounds) whenever the filter contains multi-byte UTF-8
148 // characters before the '#'.
149 if filter.len() > 1 && filter.as_bytes()[pos - 1] != b'/' {
150 return false;
151 }
152
153 // Check if # appears anywhere else
154 if filter.matches('#').count() > 1 {
155 return false;
156 }
157 }
158
159 // Single-level wildcard validation
160 if filter.contains('+') {
161 // Split by levels to check each segment
162 let levels: Vec<&str> = filter.split('/').collect();
163 for level in levels {
164 if level.contains('+') && level != "+" {
165 return false;
166 }
167 }
168 }
169
170 true
171}
172
173/// Determines if a topic name represents a system topic.
174///
175/// MQTT system topics are reserved for broker-specific functionality and
176/// typically start with the `$` character. Clients should generally avoid
177/// publishing to system topics unless specifically documented by the broker.
178///
179/// # Example
180///
181/// ```
182/// use mqute_codec::protocol::util;
183///
184/// assert!(util::is_system_topic("$SYS/monitor"));
185/// assert!(!util::is_system_topic("sensors/temperature"));
186/// ```
187pub fn is_system_topic<T: AsRef<str>>(topic: T) -> bool {
188 topic.as_ref().starts_with('$')
189}
190
191#[cfg(test)]
192mod tests {
193 use crate::protocol::util;
194
195 #[test]
196 fn test_valid_topic_names() {
197 let topic_names = vec![
198 "sport/tennis/player1",
199 "sport/tennis/player1/ranking",
200 "sport/tennis/player1/score/wimbledon",
201 "sport",
202 "sport/",
203 "/",
204 "Accounts payable",
205 "/finance",
206 "$SYS/monitor/Clients",
207 ];
208
209 for name in topic_names {
210 assert!(util::is_valid_topic_name(name));
211 }
212 }
213
214 #[test]
215 fn test_invalid_topic_names() {
216 let topic_names = vec![
217 "",
218 "sport/\0/tennis",
219 "sport/tennis/player1/#",
220 "sport+",
221 "#",
222 "sport/tennis#",
223 "sport/tennis/#/ranking",
224 ];
225
226 for name in topic_names {
227 assert!(!util::is_valid_topic_name(name));
228 }
229 }
230
231 #[test]
232 fn test_valid_topic_filters() {
233 let filters = vec![
234 "sport/tennis/player1/#",
235 "sport/#",
236 "#",
237 "sport/tennis/#",
238 "+",
239 "+/tennis/#",
240 "sport/+/player1",
241 "/finance",
242 "$SYS/#",
243 "$SYS/monitor/+",
244 ];
245
246 for filter in filters {
247 assert!(util::is_valid_topic_filter(filter));
248 }
249 }
250
251 #[test]
252 fn test_invalid_topic_filters() {
253 let filters = vec!["sport/tennis#", "sport/tennis/#/ranking", "sport+", ""];
254
255 for filter in filters {
256 assert!(!util::is_valid_topic_filter(filter));
257 }
258 }
259
260 #[test]
261 fn test_topic_filter_multibyte_utf8_before_wildcard() {
262 // A 2-byte UTF-8 character ('é') immediately followed by the valid
263 // "/#" suffix must be accepted. `pos` (byte offset of '#') and the
264 // character count diverge here, which used to make the old
265 // char-indexed implementation reject this (or worse, panic).
266 assert!(util::is_valid_topic_filter("é/#"));
267 assert!(util::is_valid_topic_filter("sensors/日本語/temperature"));
268 assert!(util::is_valid_topic_filter("日本語/#"));
269 }
270
271 #[test]
272 fn test_topic_filter_multibyte_utf8_without_separator_is_rejected_not_panicking() {
273 // A 3-byte UTF-8 character ('€') directly followed by '#' (no '/' in
274 // between) must be rejected. This used to panic because `pos - 1`,
275 // a byte offset, was passed to `chars().nth(...)`, which indexes by
276 // character and was out of bounds for this input.
277 assert!(!util::is_valid_topic_filter("€#"));
278 }
279}