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#[must_use]
205pub fn parse_shared_subscription(topic_filter: &str) -> (&str, Option<&str>) {
206    if let Some(after_share) = topic_filter.strip_prefix("$share/") {
207        if let Some(slash_pos) = after_share.find('/') {
208            let group_name = &after_share[..slash_pos];
209            let actual_filter = &after_share[slash_pos + 1..];
210            return (actual_filter, Some(group_name));
211        }
212    }
213    (topic_filter, None)
214}
215
216#[must_use]
217pub fn strip_shared_subscription_prefix(topic_filter: &str) -> &str {
218    parse_shared_subscription(topic_filter).0
219}
220
221/// Trait for pluggable topic validation
222///
223/// This trait allows customization of topic validation rules beyond the standard MQTT specification.
224/// Implementations can add additional restrictions, reserved topic prefixes, or cloud provider-specific rules.
225pub trait TopicValidator: Send + Sync {
226    /// Validates a topic name for publishing
227    ///
228    /// # Arguments
229    ///
230    /// * `topic` - The topic name to validate
231    ///
232    /// # Errors
233    ///
234    /// Returns `MqttError::InvalidTopicName` if the topic is invalid
235    fn validate_topic_name(&self, topic: &str) -> Result<()>;
236
237    /// Validates a topic filter for subscriptions
238    ///
239    /// # Arguments
240    ///
241    /// * `filter` - The topic filter to validate
242    ///
243    /// # Errors
244    ///
245    /// Returns `MqttError::InvalidTopicFilter` if the filter is invalid
246    fn validate_topic_filter(&self, filter: &str) -> Result<()>;
247
248    /// Checks if a topic is reserved and should be restricted
249    ///
250    /// # Arguments
251    ///
252    /// * `topic` - The topic to check
253    ///
254    /// # Returns
255    ///
256    /// `true` if the topic is reserved and should be restricted
257    fn is_reserved_topic(&self, topic: &str) -> bool;
258
259    /// Gets a human-readable description of the validator
260    fn description(&self) -> &'static str;
261}
262
263/// Standard MQTT specification validator
264///
265/// This validator implements the basic MQTT v5.0 specification rules for topic names and filters.
266#[derive(Debug, Clone, Default)]
267pub struct StandardValidator;
268
269impl TopicValidator for StandardValidator {
270    fn validate_topic_name(&self, topic: &str) -> Result<()> {
271        validate_topic_name(topic)
272    }
273
274    fn validate_topic_filter(&self, filter: &str) -> Result<()> {
275        validate_topic_filter(filter)
276    }
277
278    fn is_reserved_topic(&self, _topic: &str) -> bool {
279        // Standard MQTT has no reserved topics
280        false
281    }
282
283    fn description(&self) -> &'static str {
284        "Standard MQTT v5.0 specification validator"
285    }
286}
287
288/// Restrictive validator with additional constraints
289///
290/// This validator extends the standard MQTT rules with additional restrictions
291/// such as reserved topic prefixes, maximum topic levels, and custom character sets.
292#[derive(Debug, Clone, Default)]
293pub struct RestrictiveValidator {
294    /// Reserved topic prefixes that should be rejected
295    pub reserved_prefixes: Vec<String>,
296    /// Maximum number of topic levels (separated by '/')
297    pub max_levels: Option<usize>,
298    /// Maximum topic length (overrides MQTT spec if smaller)
299    pub max_topic_length: Option<usize>,
300    /// Prohibited characters beyond MQTT spec requirements
301    pub prohibited_chars: Vec<char>,
302}
303
304impl RestrictiveValidator {
305    /// Creates a new restrictive validator
306    #[must_use]
307    pub fn new() -> Self {
308        Self::default()
309    }
310
311    /// Adds a reserved topic prefix
312    #[must_use]
313    pub fn with_reserved_prefix(mut self, prefix: impl Into<String>) -> Self {
314        self.reserved_prefixes.push(prefix.into());
315        self
316    }
317
318    /// Sets the maximum number of topic levels
319    #[must_use]
320    pub fn with_max_levels(mut self, max_levels: usize) -> Self {
321        self.max_levels = Some(max_levels);
322        self
323    }
324
325    /// Sets the maximum topic length
326    #[must_use]
327    pub fn with_max_topic_length(mut self, max_length: usize) -> Self {
328        self.max_topic_length = Some(max_length);
329        self
330    }
331
332    /// Adds a prohibited character
333    #[must_use]
334    pub fn with_prohibited_char(mut self, ch: char) -> Self {
335        self.prohibited_chars.push(ch);
336        self
337    }
338
339    /// Checks if topic violates additional restrictions
340    fn check_additional_restrictions(&self, topic: &str) -> Result<()> {
341        // Check reserved prefixes
342        for prefix in &self.reserved_prefixes {
343            if topic.starts_with(prefix) {
344                return Err(MqttError::InvalidTopicName(format!(
345                    "Topic '{topic}' uses reserved prefix '{prefix}'"
346                )));
347            }
348        }
349
350        // Check maximum levels
351        if let Some(max_levels) = self.max_levels {
352            let level_count = topic.split('/').count();
353            if level_count > max_levels {
354                return Err(MqttError::InvalidTopicName(format!(
355                    "Topic '{topic}' has {level_count} levels, maximum allowed is {max_levels}"
356                )));
357            }
358        }
359
360        // Check maximum length
361        if let Some(max_length) = self.max_topic_length {
362            if topic.len() > max_length {
363                return Err(MqttError::InvalidTopicName(format!(
364                    "Topic '{}' length {} exceeds maximum {}",
365                    topic,
366                    topic.len(),
367                    max_length
368                )));
369            }
370        }
371
372        // Check prohibited characters
373        for &prohibited_char in &self.prohibited_chars {
374            if topic.contains(prohibited_char) {
375                return Err(MqttError::InvalidTopicName(format!(
376                    "Topic '{topic}' contains prohibited character '{prohibited_char}'"
377                )));
378            }
379        }
380
381        Ok(())
382    }
383}
384
385impl TopicValidator for RestrictiveValidator {
386    fn validate_topic_name(&self, topic: &str) -> Result<()> {
387        // First apply standard validation
388        validate_topic_name(topic)?;
389        // Then apply additional restrictions
390        self.check_additional_restrictions(topic)
391    }
392
393    fn validate_topic_filter(&self, filter: &str) -> Result<()> {
394        // First apply standard validation
395        validate_topic_filter(filter)?;
396        // Then apply additional restrictions (but allow wildcards)
397        // Note: We don't apply all restrictions to filters since they may contain wildcards
398
399        // Check reserved prefixes
400        for prefix in &self.reserved_prefixes {
401            if filter.starts_with(prefix) && !filter.contains('+') && !filter.contains('#') {
402                return Err(MqttError::InvalidTopicFilter(format!(
403                    "Topic filter '{filter}' uses reserved prefix '{prefix}'"
404                )));
405            }
406        }
407
408        Ok(())
409    }
410
411    fn is_reserved_topic(&self, topic: &str) -> bool {
412        self.reserved_prefixes
413            .iter()
414            .any(|prefix| topic.starts_with(prefix))
415    }
416
417    fn description(&self) -> &'static str {
418        "Restrictive validator with additional constraints"
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn test_valid_topic_names() {
428        assert!(is_valid_topic_name("sport/tennis"));
429        assert!(is_valid_topic_name("sport/tennis/player1"));
430        assert!(is_valid_topic_name("home/temperature"));
431        assert!(is_valid_topic_name("/"));
432        assert!(is_valid_topic_name("a"));
433    }
434
435    #[test]
436    fn test_invalid_topic_names() {
437        assert!(!is_valid_topic_name(""));
438        assert!(!is_valid_topic_name("sport/+/player"));
439        assert!(!is_valid_topic_name("sport/tennis/#"));
440        assert!(!is_valid_topic_name("home\0temperature"));
441
442        let too_long = "a".repeat(crate::constants::limits::MAX_BINARY_LENGTH as usize);
443        assert!(!is_valid_topic_name(&too_long));
444    }
445
446    #[test]
447    fn test_valid_topic_filters() {
448        assert!(is_valid_topic_filter("sport/tennis"));
449        assert!(is_valid_topic_filter("sport/+/player"));
450        assert!(is_valid_topic_filter("sport/tennis/#"));
451        assert!(is_valid_topic_filter("#"));
452        assert!(is_valid_topic_filter("+"));
453        assert!(is_valid_topic_filter("+/tennis/#"));
454        assert!(is_valid_topic_filter("sport/+"));
455    }
456
457    #[test]
458    fn test_invalid_topic_filters() {
459        assert!(!is_valid_topic_filter(""));
460        assert!(!is_valid_topic_filter("sport/tennis#"));
461        assert!(!is_valid_topic_filter("sport/tennis/#/ranking"));
462        assert!(!is_valid_topic_filter("sport+"));
463        assert!(!is_valid_topic_filter("sport/+tennis"));
464        assert!(!is_valid_topic_filter("home\0temperature"));
465    }
466
467    #[test]
468    fn test_valid_client_ids() {
469        assert!(is_valid_client_id(""));
470        assert!(is_valid_client_id("client123"));
471        assert!(is_valid_client_id("MyClient"));
472        assert!(is_valid_client_id("123456789012345678901234"));
473        assert!(is_valid_client_id("a1b2c3d4e5f6"));
474    }
475
476    #[test]
477    fn test_invalid_client_ids() {
478        assert!(!is_valid_client_id("client-123"));
479        assert!(!is_valid_client_id("client.123"));
480        assert!(!is_valid_client_id("client 123"));
481        assert!(!is_valid_client_id("client@123"));
482
483        let too_long = "a".repeat(crate::constants::limits::MAX_CLIENT_ID_LENGTH + 1);
484        assert!(!is_valid_client_id(&too_long));
485    }
486
487    #[test]
488    fn test_topic_matches_filter() {
489        // Exact matches
490        assert!(topic_matches_filter("sport/tennis", "sport/tennis"));
491
492        // Single-level wildcard
493        assert!(topic_matches_filter("sport/tennis", "sport/+"));
494        assert!(topic_matches_filter(
495            "sport/tennis/player1",
496            "sport/+/player1"
497        ));
498        assert!(topic_matches_filter(
499            "sport/tennis/player1",
500            "sport/tennis/+"
501        ));
502        assert!(!topic_matches_filter("sport/tennis/player1", "sport/+"));
503
504        // Multi-level wildcard
505        assert!(topic_matches_filter("sport/tennis", "sport/#"));
506        assert!(topic_matches_filter("sport/tennis/player1", "sport/#"));
507        assert!(topic_matches_filter(
508            "sport/tennis/player1/ranking",
509            "sport/#"
510        ));
511        assert!(topic_matches_filter("sport", "sport/#"));
512        assert!(topic_matches_filter("anything", "#"));
513        assert!(topic_matches_filter("sport/tennis", "#"));
514
515        // $ prefix topics - MQTT spec compliant behavior
516        assert!(!topic_matches_filter("$SYS/broker/uptime", "#"));
517        assert!(!topic_matches_filter(
518            "$SYS/broker/uptime",
519            "+/broker/uptime"
520        ));
521        assert!(!topic_matches_filter("$data/temp", "+/temp"));
522        assert!(topic_matches_filter("$SYS/broker/uptime", "$SYS/#"));
523        assert!(topic_matches_filter("$SYS/broker/uptime", "$SYS/+/uptime"));
524
525        // Non-matches
526        assert!(!topic_matches_filter("sport/tennis", "sport/football"));
527        assert!(!topic_matches_filter("sport", "sport/tennis"));
528        assert!(!topic_matches_filter(
529            "sport/tennis/player1",
530            "sport/tennis"
531        ));
532    }
533
534    #[test]
535    fn test_parse_shared_subscription() {
536        assert_eq!(
537            parse_shared_subscription("$share/workers/tasks/+"),
538            ("tasks/+", Some("workers"))
539        );
540        assert_eq!(
541            parse_shared_subscription("$share/group1/sensor/temperature"),
542            ("sensor/temperature", Some("group1"))
543        );
544        assert_eq!(
545            parse_shared_subscription("$share/mygroup/#"),
546            ("#", Some("mygroup"))
547        );
548        assert_eq!(
549            parse_shared_subscription("normal/topic"),
550            ("normal/topic", None)
551        );
552        assert_eq!(parse_shared_subscription("#"), ("#", None));
553        assert_eq!(
554            parse_shared_subscription("$share/incomplete"),
555            ("$share/incomplete", None)
556        );
557        assert_eq!(parse_shared_subscription(""), ("", None));
558    }
559
560    #[test]
561    fn test_strip_shared_subscription_prefix() {
562        assert_eq!(
563            strip_shared_subscription_prefix("$share/workers/tasks/+"),
564            "tasks/+"
565        );
566        assert_eq!(
567            strip_shared_subscription_prefix("$share/group1/sensor/temp"),
568            "sensor/temp"
569        );
570        assert_eq!(
571            strip_shared_subscription_prefix("normal/topic"),
572            "normal/topic"
573        );
574        assert_eq!(strip_shared_subscription_prefix("#"), "#");
575        assert_eq!(
576            strip_shared_subscription_prefix("$share/nofilter"),
577            "$share/nofilter"
578        );
579    }
580}