Skip to main content

mqtt5_protocol/validation/
mod.rs

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