mqtt5_protocol/validation/
mod.rs

1use crate::error::{MqttError, Result};
2
3pub mod namespace;
4
5/// Validates an MQTT topic name according to MQTT v5.0 specification
6///
7/// # Rules:
8/// - Must be UTF-8 encoded
9/// - Must have at least one character
10/// - Must not contain null characters (U+0000)
11/// - Must not exceed maximum string length when UTF-8 encoded
12/// - Should not contain wildcard characters (+, #) in topic names (only in filters)
13#[must_use]
14pub fn is_valid_topic_name(topic: &str) -> bool {
15    if topic.is_empty() {
16        return false;
17    }
18
19    if topic.len() > crate::constants::limits::MAX_STRING_LENGTH as usize {
20        return false;
21    }
22
23    if topic.contains('\0') {
24        return false;
25    }
26
27    // Topic names should not contain wildcards
28    if topic.contains('+') || topic.contains('#') {
29        return false;
30    }
31
32    true
33}
34
35/// Validates an MQTT topic filter according to MQTT v5.0 specification
36///
37/// # Rules:
38/// - Must follow all topic name rules except wildcard usage
39/// - Single-level wildcard (+) must occupy entire level
40/// - Multi-level wildcard (#) must be last character and occupy entire level
41/// - Examples: sport/+/player, sport/tennis/#, +/tennis/#
42#[must_use]
43pub fn is_valid_topic_filter(filter: &str) -> bool {
44    if filter.is_empty() {
45        return false;
46    }
47
48    if filter.len() > crate::constants::limits::MAX_STRING_LENGTH as usize {
49        return false;
50    }
51
52    if filter.contains('\0') {
53        return false;
54    }
55
56    let parts: Vec<&str> = filter.split('/').collect();
57
58    for (i, part) in parts.iter().enumerate() {
59        // Multi-level wildcard rules
60        if part.contains('#') {
61            // # must be the last character in the filter
62            if i != parts.len() - 1 {
63                return false;
64            }
65            // # must occupy the entire level
66            if *part != "#" {
67                return false;
68            }
69        }
70
71        // Single-level wildcard rules
72        if part.contains('+') {
73            // + must occupy the entire level
74            if *part != "+" {
75                return false;
76            }
77        }
78    }
79
80    true
81}
82
83/// Validates an MQTT client identifier according to MQTT v5.0 specification
84///
85/// # Rules:
86/// - Must be UTF-8 encoded
87/// - Must contain only characters: 0-9, a-z, A-Z
88/// - Must be between 1 and 23 bytes (unless server supports longer)
89/// - Empty string is allowed (server will assign one)
90#[must_use]
91pub fn is_valid_client_id(client_id: &str) -> bool {
92    if client_id.is_empty() {
93        return true; // Empty client ID is allowed
94    }
95
96    if client_id.len() > 23 {
97        // Most servers support longer, but 23 is the spec minimum
98        // We'll allow longer and let the server reject if needed
99        if client_id.len() > crate::constants::limits::MAX_CLIENT_ID_LENGTH {
100            return false; // Reasonable upper limit
101        }
102    }
103
104    // Check for valid characters (alphanumeric)
105    client_id.chars().all(|c| c.is_ascii_alphanumeric())
106}
107
108/// Validates a topic name and returns an error if invalid
109///
110/// # Errors
111///
112/// Returns `MqttError::InvalidTopicName` if the topic name:
113/// - Is empty
114/// - Exceeds maximum string length
115/// - Contains null characters
116/// - Contains wildcard characters (+, #)
117pub fn validate_topic_name(topic: &str) -> Result<()> {
118    if !is_valid_topic_name(topic) {
119        return Err(MqttError::InvalidTopicName(topic.to_string()));
120    }
121    Ok(())
122}
123
124/// Validates a topic filter and returns an error if invalid
125///
126/// # Errors
127///
128/// Returns `MqttError::InvalidTopicFilter` if the topic filter:
129/// - Is empty
130/// - Exceeds maximum string length
131/// - Contains null characters
132/// - Has invalid wildcard usage
133pub fn validate_topic_filter(filter: &str) -> Result<()> {
134    if !is_valid_topic_filter(filter) {
135        return Err(MqttError::InvalidTopicFilter(filter.to_string()));
136    }
137    Ok(())
138}
139
140/// Validates a client ID and returns an error if invalid
141///
142/// # Errors
143///
144/// Returns `MqttError::InvalidClientId` if the client ID:
145/// - Contains non-alphanumeric characters
146/// - Exceeds maximum client ID length
147pub fn validate_client_id(client_id: &str) -> Result<()> {
148    if !is_valid_client_id(client_id) {
149        return Err(MqttError::InvalidClientId(client_id.to_string()));
150    }
151    Ok(())
152}
153
154/// Checks if a topic name matches a topic filter
155///
156/// # Rules:
157/// - '+' matches exactly one topic level
158/// - '#' matches any number of levels including parent level
159/// - Topic and filter level separators must match exactly
160/// - Topics starting with '$' do NOT match root-level wildcards (MQTT spec)
161#[must_use]
162pub fn topic_matches_filter(topic: &str, filter: &str) -> bool {
163    // MQTT spec: topics starting with $ do not match wildcards at root level
164    if topic.starts_with('$') && (filter.starts_with('#') || filter.starts_with('+')) {
165        return false;
166    }
167
168    if filter == "#" {
169        return true;
170    }
171
172    let topic_parts: Vec<&str> = topic.split('/').collect();
173    let filter_parts: Vec<&str> = filter.split('/').collect();
174
175    let mut t_idx = 0;
176    let mut f_idx = 0;
177
178    while t_idx < topic_parts.len() && f_idx < filter_parts.len() {
179        if filter_parts[f_idx] == "#" {
180            return true; // Multi-level wildcard matches everything remaining
181        }
182
183        if filter_parts[f_idx] != "+" && filter_parts[f_idx] != topic_parts[t_idx] {
184            return false; // Not a match
185        }
186
187        t_idx += 1;
188        f_idx += 1;
189    }
190
191    // Check if we've consumed both topic and filter
192    if t_idx == topic_parts.len() && f_idx == filter_parts.len() {
193        return true;
194    }
195
196    // Check if filter ends with # and we've consumed the topic
197    if t_idx == topic_parts.len() && f_idx == filter_parts.len() - 1 && filter_parts[f_idx] == "#" {
198        return true;
199    }
200
201    false
202}
203
204/// Trait for pluggable topic validation
205///
206/// This trait allows customization of topic validation rules beyond the standard MQTT specification.
207/// Implementations can add additional restrictions, reserved topic prefixes, or cloud provider-specific rules.
208pub trait TopicValidator: Send + Sync {
209    /// Validates a topic name for publishing
210    ///
211    /// # Arguments
212    ///
213    /// * `topic` - The topic name to validate
214    ///
215    /// # Errors
216    ///
217    /// Returns `MqttError::InvalidTopicName` if the topic is invalid
218    fn validate_topic_name(&self, topic: &str) -> Result<()>;
219
220    /// Validates a topic filter for subscriptions
221    ///
222    /// # Arguments
223    ///
224    /// * `filter` - The topic filter to validate
225    ///
226    /// # Errors
227    ///
228    /// Returns `MqttError::InvalidTopicFilter` if the filter is invalid
229    fn validate_topic_filter(&self, filter: &str) -> Result<()>;
230
231    /// Checks if a topic is reserved and should be restricted
232    ///
233    /// # Arguments
234    ///
235    /// * `topic` - The topic to check
236    ///
237    /// # Returns
238    ///
239    /// `true` if the topic is reserved and should be restricted
240    fn is_reserved_topic(&self, topic: &str) -> bool;
241
242    /// Gets a human-readable description of the validator
243    fn description(&self) -> &'static str;
244}
245
246/// Standard MQTT specification validator
247///
248/// This validator implements the basic MQTT v5.0 specification rules for topic names and filters.
249#[derive(Debug, Clone, Default)]
250pub struct StandardValidator;
251
252impl TopicValidator for StandardValidator {
253    fn validate_topic_name(&self, topic: &str) -> Result<()> {
254        validate_topic_name(topic)
255    }
256
257    fn validate_topic_filter(&self, filter: &str) -> Result<()> {
258        validate_topic_filter(filter)
259    }
260
261    fn is_reserved_topic(&self, _topic: &str) -> bool {
262        // Standard MQTT has no reserved topics
263        false
264    }
265
266    fn description(&self) -> &'static str {
267        "Standard MQTT v5.0 specification validator"
268    }
269}
270
271/// Restrictive validator with additional constraints
272///
273/// This validator extends the standard MQTT rules with additional restrictions
274/// such as reserved topic prefixes, maximum topic levels, and custom character sets.
275#[derive(Debug, Clone, Default)]
276pub struct RestrictiveValidator {
277    /// Reserved topic prefixes that should be rejected
278    pub reserved_prefixes: Vec<String>,
279    /// Maximum number of topic levels (separated by '/')
280    pub max_levels: Option<usize>,
281    /// Maximum topic length (overrides MQTT spec if smaller)
282    pub max_topic_length: Option<usize>,
283    /// Prohibited characters beyond MQTT spec requirements
284    pub prohibited_chars: Vec<char>,
285}
286
287impl RestrictiveValidator {
288    /// Creates a new restrictive validator
289    #[must_use]
290    pub fn new() -> Self {
291        Self::default()
292    }
293
294    /// Adds a reserved topic prefix
295    #[must_use]
296    pub fn with_reserved_prefix(mut self, prefix: impl Into<String>) -> Self {
297        self.reserved_prefixes.push(prefix.into());
298        self
299    }
300
301    /// Sets the maximum number of topic levels
302    #[must_use]
303    pub fn with_max_levels(mut self, max_levels: usize) -> Self {
304        self.max_levels = Some(max_levels);
305        self
306    }
307
308    /// Sets the maximum topic length
309    #[must_use]
310    pub fn with_max_topic_length(mut self, max_length: usize) -> Self {
311        self.max_topic_length = Some(max_length);
312        self
313    }
314
315    /// Adds a prohibited character
316    #[must_use]
317    pub fn with_prohibited_char(mut self, ch: char) -> Self {
318        self.prohibited_chars.push(ch);
319        self
320    }
321
322    /// Checks if topic violates additional restrictions
323    fn check_additional_restrictions(&self, topic: &str) -> Result<()> {
324        // Check reserved prefixes
325        for prefix in &self.reserved_prefixes {
326            if topic.starts_with(prefix) {
327                return Err(MqttError::InvalidTopicName(format!(
328                    "Topic '{topic}' uses reserved prefix '{prefix}'"
329                )));
330            }
331        }
332
333        // Check maximum levels
334        if let Some(max_levels) = self.max_levels {
335            let level_count = topic.split('/').count();
336            if level_count > max_levels {
337                return Err(MqttError::InvalidTopicName(format!(
338                    "Topic '{topic}' has {level_count} levels, maximum allowed is {max_levels}"
339                )));
340            }
341        }
342
343        // Check maximum length
344        if let Some(max_length) = self.max_topic_length {
345            if topic.len() > max_length {
346                return Err(MqttError::InvalidTopicName(format!(
347                    "Topic '{}' length {} exceeds maximum {}",
348                    topic,
349                    topic.len(),
350                    max_length
351                )));
352            }
353        }
354
355        // Check prohibited characters
356        for &prohibited_char in &self.prohibited_chars {
357            if topic.contains(prohibited_char) {
358                return Err(MqttError::InvalidTopicName(format!(
359                    "Topic '{topic}' contains prohibited character '{prohibited_char}'"
360                )));
361            }
362        }
363
364        Ok(())
365    }
366}
367
368impl TopicValidator for RestrictiveValidator {
369    fn validate_topic_name(&self, topic: &str) -> Result<()> {
370        // First apply standard validation
371        validate_topic_name(topic)?;
372        // Then apply additional restrictions
373        self.check_additional_restrictions(topic)
374    }
375
376    fn validate_topic_filter(&self, filter: &str) -> Result<()> {
377        // First apply standard validation
378        validate_topic_filter(filter)?;
379        // Then apply additional restrictions (but allow wildcards)
380        // Note: We don't apply all restrictions to filters since they may contain wildcards
381
382        // Check reserved prefixes
383        for prefix in &self.reserved_prefixes {
384            if filter.starts_with(prefix) && !filter.contains('+') && !filter.contains('#') {
385                return Err(MqttError::InvalidTopicFilter(format!(
386                    "Topic filter '{filter}' uses reserved prefix '{prefix}'"
387                )));
388            }
389        }
390
391        Ok(())
392    }
393
394    fn is_reserved_topic(&self, topic: &str) -> bool {
395        self.reserved_prefixes
396            .iter()
397            .any(|prefix| topic.starts_with(prefix))
398    }
399
400    fn description(&self) -> &'static str {
401        "Restrictive validator with additional constraints"
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_valid_topic_names() {
411        assert!(is_valid_topic_name("sport/tennis"));
412        assert!(is_valid_topic_name("sport/tennis/player1"));
413        assert!(is_valid_topic_name("home/temperature"));
414        assert!(is_valid_topic_name("/"));
415        assert!(is_valid_topic_name("a"));
416    }
417
418    #[test]
419    fn test_invalid_topic_names() {
420        assert!(!is_valid_topic_name(""));
421        assert!(!is_valid_topic_name("sport/+/player"));
422        assert!(!is_valid_topic_name("sport/tennis/#"));
423        assert!(!is_valid_topic_name("home\0temperature"));
424
425        let too_long = "a".repeat(crate::constants::limits::MAX_BINARY_LENGTH as usize);
426        assert!(!is_valid_topic_name(&too_long));
427    }
428
429    #[test]
430    fn test_valid_topic_filters() {
431        assert!(is_valid_topic_filter("sport/tennis"));
432        assert!(is_valid_topic_filter("sport/+/player"));
433        assert!(is_valid_topic_filter("sport/tennis/#"));
434        assert!(is_valid_topic_filter("#"));
435        assert!(is_valid_topic_filter("+"));
436        assert!(is_valid_topic_filter("+/tennis/#"));
437        assert!(is_valid_topic_filter("sport/+"));
438    }
439
440    #[test]
441    fn test_invalid_topic_filters() {
442        assert!(!is_valid_topic_filter(""));
443        assert!(!is_valid_topic_filter("sport/tennis#"));
444        assert!(!is_valid_topic_filter("sport/tennis/#/ranking"));
445        assert!(!is_valid_topic_filter("sport+"));
446        assert!(!is_valid_topic_filter("sport/+tennis"));
447        assert!(!is_valid_topic_filter("home\0temperature"));
448    }
449
450    #[test]
451    fn test_valid_client_ids() {
452        assert!(is_valid_client_id(""));
453        assert!(is_valid_client_id("client123"));
454        assert!(is_valid_client_id("MyClient"));
455        assert!(is_valid_client_id("123456789012345678901234"));
456        assert!(is_valid_client_id("a1b2c3d4e5f6"));
457    }
458
459    #[test]
460    fn test_invalid_client_ids() {
461        assert!(!is_valid_client_id("client-123"));
462        assert!(!is_valid_client_id("client.123"));
463        assert!(!is_valid_client_id("client 123"));
464        assert!(!is_valid_client_id("client@123"));
465
466        let too_long = "a".repeat(crate::constants::limits::MAX_CLIENT_ID_LENGTH + 1);
467        assert!(!is_valid_client_id(&too_long));
468    }
469
470    #[test]
471    fn test_topic_matches_filter() {
472        // Exact matches
473        assert!(topic_matches_filter("sport/tennis", "sport/tennis"));
474
475        // Single-level wildcard
476        assert!(topic_matches_filter("sport/tennis", "sport/+"));
477        assert!(topic_matches_filter(
478            "sport/tennis/player1",
479            "sport/+/player1"
480        ));
481        assert!(topic_matches_filter(
482            "sport/tennis/player1",
483            "sport/tennis/+"
484        ));
485        assert!(!topic_matches_filter("sport/tennis/player1", "sport/+"));
486
487        // Multi-level wildcard
488        assert!(topic_matches_filter("sport/tennis", "sport/#"));
489        assert!(topic_matches_filter("sport/tennis/player1", "sport/#"));
490        assert!(topic_matches_filter(
491            "sport/tennis/player1/ranking",
492            "sport/#"
493        ));
494        assert!(topic_matches_filter("sport", "sport/#"));
495        assert!(topic_matches_filter("anything", "#"));
496        assert!(topic_matches_filter("sport/tennis", "#"));
497
498        // $ prefix topics - MQTT spec compliant behavior
499        assert!(!topic_matches_filter("$SYS/broker/uptime", "#"));
500        assert!(!topic_matches_filter(
501            "$SYS/broker/uptime",
502            "+/broker/uptime"
503        ));
504        assert!(!topic_matches_filter("$data/temp", "+/temp"));
505        assert!(topic_matches_filter("$SYS/broker/uptime", "$SYS/#"));
506        assert!(topic_matches_filter("$SYS/broker/uptime", "$SYS/+/uptime"));
507
508        // Non-matches
509        assert!(!topic_matches_filter("sport/tennis", "sport/football"));
510        assert!(!topic_matches_filter("sport", "sport/tennis"));
511        assert!(!topic_matches_filter(
512            "sport/tennis/player1",
513            "sport/tennis"
514        ));
515    }
516}