mqtt5_protocol/
topic_matching.rs

1/// Comprehensive topic matching implementation for MQTT
2/// This module provides the core topic matching algorithm with full support
3/// for single-level (+) and multi-level (#) wildcards according to MQTT spec
4use crate::error::{MqttError, Result};
5
6/// Matches a topic name against a topic filter with wildcard support
7///
8/// # Arguments
9/// * `topic` - The topic name to match (no wildcards allowed)
10/// * `filter` - The topic filter which may contain wildcards
11///
12/// # Returns
13/// * `true` if the topic matches the filter
14/// * `false` otherwise
15///
16/// # Examples
17/// ```
18/// # use mqtt5_protocol::topic_matching::matches;
19/// assert!(matches("sport/tennis", "sport/tennis"));
20/// assert!(matches("sport/tennis", "sport/+"));
21/// assert!(matches("sport/tennis/player1", "sport/#"));
22/// assert!(!matches("sport/tennis", "sport/+/player1"));
23/// ```
24#[must_use]
25pub fn matches(topic: &str, filter: &str) -> bool {
26    // Empty topic doesn't match anything
27    if topic.is_empty() {
28        return false;
29    }
30
31    // Validate inputs
32    if !is_valid_topic(topic) || !is_valid_filter(filter) {
33        return false;
34    }
35
36    // Fast path for exact match
37    if topic == filter {
38        return true;
39    }
40
41    // MQTT spec: topics starting with $ do not match wildcards at root level
42    if topic.starts_with('$') && (filter.starts_with('#') || filter.starts_with('+')) {
43        return false;
44    }
45
46    // Fast path for # at root
47    if filter == "#" {
48        return true;
49    }
50
51    let topic_parts: Vec<&str> = topic.split('/').collect();
52    let filter_parts: Vec<&str> = filter.split('/').collect();
53
54    match_parts(&topic_parts, &filter_parts)
55}
56
57/// Recursive helper for matching topic parts against filter parts
58fn match_parts(topic_parts: &[&str], filter_parts: &[&str]) -> bool {
59    match (topic_parts.first(), filter_parts.first()) {
60        // Both exhausted - match
61        (None, None) => true,
62
63        // Filter has # - matches everything remaining
64        (_, Some(&"#")) => filter_parts.len() == 1, // # must be last
65
66        // One exhausted but not both - no match
67        (None, Some(_)) | (Some(_), None) => false,
68
69        // Both have parts
70        (Some(&topic_part), Some(&filter_part)) => {
71            // Check current level match
72            let level_match = filter_part == "+" || filter_part == topic_part;
73
74            // If current level matches, check remaining parts
75            level_match && match_parts(&topic_parts[1..], &filter_parts[1..])
76        }
77    }
78}
79
80/// Validates a topic name (no wildcards allowed)
81#[must_use]
82pub fn is_valid_topic(topic: &str) -> bool {
83    // Empty topic is actually valid in MQTT (e.g., for will messages)
84    !topic.contains('\0') && !topic.contains('+') && !topic.contains('#') && topic.len() <= 65535
85}
86
87/// Validates a topic filter (may contain wildcards)
88#[must_use]
89pub fn is_valid_filter(filter: &str) -> bool {
90    if filter.is_empty() || filter.contains('\0') || filter.len() > 65535 {
91        return false;
92    }
93
94    let parts: Vec<&str> = filter.split('/').collect();
95
96    for (i, part) in parts.iter().enumerate() {
97        // # must be alone and last
98        if part.contains('#') {
99            return *part == "#" && i == parts.len() - 1;
100        }
101
102        // + must be alone in its level
103        if part.contains('+') && *part != "+" {
104            return false;
105        }
106    }
107
108    true
109}
110
111/// Validates a topic and returns an error if invalid
112///
113/// # Errors
114/// Returns `MqttError::InvalidTopicName` if the topic is invalid
115pub fn validate_topic(topic: &str) -> Result<()> {
116    if !is_valid_topic(topic) {
117        return Err(MqttError::InvalidTopicName(format!(
118            "Invalid topic: {}",
119            if topic.is_empty() {
120                "empty topic"
121            } else if topic.contains('+') || topic.contains('#') {
122                "wildcards not allowed in topic names"
123            } else if topic.contains('\0') {
124                "null character not allowed"
125            } else if topic.len() > 65535 {
126                "topic too long"
127            } else {
128                "unknown error"
129            }
130        )));
131    }
132    Ok(())
133}
134
135/// Validates a topic filter and returns an error if invalid
136///
137/// # Errors
138/// Returns `MqttError::InvalidTopicFilter` if the filter is invalid
139pub fn validate_filter(filter: &str) -> Result<()> {
140    if !is_valid_filter(filter) {
141        return Err(MqttError::InvalidTopicFilter(format!(
142            "Invalid filter: {}",
143            if filter.is_empty() {
144                "empty filter"
145            } else if filter.contains('\0') {
146                "null character not allowed"
147            } else if filter.len() > 65535 {
148                "filter too long"
149            } else {
150                "invalid wildcard usage"
151            }
152        )));
153    }
154    Ok(())
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_exact_match() {
163        assert!(matches("sport/tennis", "sport/tennis"));
164        assert!(matches("/", "/"));
165        assert!(matches("sport", "sport"));
166        assert!(!matches("sport", "sports"));
167        assert!(!matches("sport/tennis", "sport/tennis/player1"));
168    }
169
170    #[test]
171    fn test_single_level_wildcard() {
172        // Basic + usage
173        assert!(matches("sport/tennis", "sport/+"));
174        assert!(matches("sport/", "sport/+"));
175        assert!(!matches("sport/tennis/player1", "sport/+"));
176
177        // Multiple + in filter
178        assert!(matches("sport/tennis/player1", "sport/+/+"));
179        assert!(matches("sport/tennis/player1", "+/+/+"));
180        assert!(!matches("sport/tennis", "+/+/+"));
181
182        // + at different positions
183        assert!(matches("sport/tennis", "+/tennis"));
184        assert!(matches("sport/tennis/player1", "sport/tennis/+"));
185        assert!(matches("/tennis", "+/tennis"));
186        assert!(matches("sport/", "sport/+"));
187    }
188
189    #[test]
190    fn test_multi_level_wildcard() {
191        // # at end
192        assert!(matches("sport", "sport/#"));
193        assert!(matches("sport/", "sport/#"));
194        assert!(matches("sport/tennis", "sport/#"));
195        assert!(matches("sport/tennis/player1", "sport/#"));
196        assert!(matches("sport/tennis/player1/ranking", "sport/#"));
197
198        // # at root
199        assert!(matches("sport", "#"));
200        assert!(matches("sport/tennis", "#"));
201        assert!(!matches("", "#")); // Empty topic never matches
202        assert!(matches("/", "#"));
203
204        // # not matching parent
205        assert!(!matches("sports", "sport/#"));
206        assert!(!matches("", "sport/#"));
207    }
208
209    #[test]
210    fn test_mixed_wildcards() {
211        assert!(matches("sport/tennis/player1", "sport/+/#"));
212        assert!(matches("sport/tennis", "sport/+/#"));
213        assert!(!matches("sport", "sport/+/#"));
214
215        assert!(matches("/finance", "+/+/#"));
216        assert!(matches("/finance/", "+/+/#"));
217        assert!(matches("/finance/stock", "+/+/#"));
218        assert!(matches("/", "+/+/#")); // Actually matches: empty/empty/#
219    }
220
221    #[test]
222    fn test_edge_cases() {
223        // Empty levels
224        assert!(matches("/", "/"));
225        assert!(matches("/finance", "/finance"));
226        assert!(matches("//", "//"));
227        assert!(matches("/finance", "/+"));
228        assert!(matches("/", "/+")); // + matches empty string
229        assert!(!matches("//", "/+")); // /+ has 2 levels, // has 3 levels
230
231        // System topics starting with $ - MQTT spec compliant behavior
232        assert!(matches("$SYS/broker/uptime", "$SYS/broker/uptime"));
233        assert!(matches("$SYS/broker/uptime", "$SYS/+/uptime"));
234        assert!(matches("$SYS/broker/uptime", "$SYS/#"));
235        assert!(!matches("$SYS/broker/uptime", "#"));
236        assert!(!matches("$SYS/broker/uptime", "+/broker/uptime"));
237        assert!(!matches("$SYS/broker/uptime", "+/#"));
238
239        // Long topics
240        let long_topic = "a/".repeat(100) + "end";
241        let long_filter = "a/".repeat(100) + "end";
242        assert!(matches(&long_topic, &long_filter));
243        assert!(matches(&long_topic, "#"));
244
245        // $ prefix topics with long paths
246        let long_sys_topic = "$".to_string() + &"a/".repeat(100) + "end";
247        assert!(!matches(&long_sys_topic, "#"));
248    }
249
250    #[test]
251    fn test_dollar_prefix_wildcard_exclusion() {
252        // MQTT spec: Topics starting with $ should NOT match root-level wildcards
253        // This prevents system topics from being accidentally received
254
255        // $ topics should NOT match # at root
256        assert!(!matches("$SYS/broker/uptime", "#"));
257        assert!(!matches("$data/sensor/temp", "#"));
258        assert!(!matches("$", "#"));
259
260        // $ topics should NOT match + at root
261        assert!(!matches("$SYS/broker/uptime", "+/broker/uptime"));
262        assert!(!matches("$data/sensor/temp", "+/sensor/temp"));
263        assert!(!matches("$SYS", "+"));
264
265        // $ topics should NOT match combinations starting with wildcards
266        assert!(!matches("$SYS/broker/uptime", "+/#"));
267        assert!(!matches("$SYS/broker/uptime", "+/+/uptime"));
268
269        // $ topics SHOULD match when explicitly subscribed with $
270        assert!(matches("$SYS/broker/uptime", "$SYS/broker/uptime"));
271        assert!(matches("$SYS/broker/uptime", "$SYS/+/uptime"));
272        assert!(matches("$SYS/broker/uptime", "$SYS/#"));
273        assert!(matches("$data/sensor/temp", "$data/#"));
274        assert!(matches("$SYS/broker/uptime", "$SYS/broker/+"));
275
276        // Non-$ topics should still match # and +
277        assert!(matches("SYS/broker/uptime", "#"));
278        assert!(matches("data/sensor/temp", "+/sensor/temp"));
279        assert!(matches("normal/topic", "#"));
280
281        // Edge case: topic with $ not at start should match wildcards
282        assert!(matches("prefix/$SYS/data", "#"));
283        assert!(matches("prefix/$SYS/data", "+/$SYS/data"));
284        assert!(matches("prefix/$SYS/data", "prefix/#"));
285    }
286
287    #[test]
288    fn test_invalid_inputs() {
289        // Invalid topics
290        assert!(!matches("sport/tennis+", "sport/tennis+"));
291        assert!(!matches("sport/tennis#", "sport/tennis#"));
292        assert!(!matches("", "")); // Empty filter is invalid
293        assert!(!matches("sport\0tennis", "sport\0tennis"));
294
295        // Invalid filters
296        assert!(!matches("sport/tennis", "sport/tennis/#/extra"));
297        assert!(!matches("sport/tennis", "sport/+tennis"));
298        assert!(!matches("sport/tennis", "sport/#extra"));
299    }
300
301    #[test]
302    fn test_validation() {
303        // Valid topics
304        assert!(is_valid_topic("sport/tennis"));
305        assert!(is_valid_topic("sport"));
306        assert!(is_valid_topic("/"));
307        assert!(is_valid_topic("a"));
308
309        // Invalid topics
310        assert!(is_valid_topic("")); // Empty is valid
311        assert!(!is_valid_topic("sport/+"));
312        assert!(!is_valid_topic("sport/#"));
313        assert!(!is_valid_topic("sport\0tennis"));
314        assert!(!is_valid_topic(&"a".repeat(65536)));
315
316        // Valid filters
317        assert!(is_valid_filter("sport/tennis"));
318        assert!(is_valid_filter("sport/+"));
319        assert!(is_valid_filter("sport/#"));
320        assert!(is_valid_filter("+/+/+"));
321        assert!(is_valid_filter("#"));
322
323        // Invalid filters
324        assert!(!is_valid_filter(""));
325        assert!(!is_valid_filter("sport/+tennis"));
326        assert!(!is_valid_filter("sport/#/extra"));
327        assert!(!is_valid_filter("sport/tennis#"));
328        assert!(!is_valid_filter("sport\0tennis"));
329        assert!(!is_valid_filter(&"a".repeat(65536)));
330    }
331
332    #[test]
333    fn test_error_messages() {
334        // Test empty topic validation
335        assert!(validate_topic("").is_ok()); // Empty topic is valid in MQTT
336
337        assert_eq!(
338            validate_topic("sport/+").unwrap_err().to_string(),
339            "Invalid topic name: Invalid topic: wildcards not allowed in topic names"
340        );
341
342        assert_eq!(
343            validate_filter("sport/+tennis").unwrap_err().to_string(),
344            "Invalid topic filter: Invalid filter: invalid wildcard usage"
345        );
346    }
347}