Skip to main content

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}