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/// Checks whether a client ID is safe for use in filesystem paths.
113///
114/// Rejects path traversal sequences (`..`), path separators (`/`, `\`),
115/// null bytes, and control characters while allowing common client ID
116/// characters like hyphens, underscores, and dots (when not part of `..`).
117#[must_use]
118pub fn is_path_safe_client_id(client_id: &str) -> bool {
119    if client_id.is_empty() {
120        return true;
121    }
122
123    if client_id.len() > crate::constants::limits::MAX_CLIENT_ID_LENGTH {
124        return false;
125    }
126
127    if client_id.contains('/') || client_id.contains('\\') || client_id.contains('\0') {
128        return false;
129    }
130
131    if client_id == "." || client_id == ".." || client_id.starts_with("../") {
132        return false;
133    }
134
135    client_id.chars().all(|c| !c.is_ascii_control())
136}
137
138/// Validates a topic name and returns an error if invalid
139///
140/// # Errors
141///
142/// Returns `MqttError::InvalidTopicName` if the topic name:
143/// - Is empty
144/// - Exceeds maximum string length
145/// - Contains null characters
146/// - Contains wildcard characters (+, #)
147pub fn validate_topic_name(topic: &str) -> Result<()> {
148    if !is_valid_topic_name(topic) {
149        return Err(MqttError::InvalidTopicName(topic.to_string()));
150    }
151    Ok(())
152}
153
154/// Validates a topic filter and returns an error if invalid
155///
156/// # Errors
157///
158/// Returns `MqttError::InvalidTopicFilter` if the topic filter:
159/// - Is empty
160/// - Exceeds maximum string length
161/// - Contains null characters
162/// - Has invalid wildcard usage
163pub fn validate_topic_filter(filter: &str) -> Result<()> {
164    if !is_valid_topic_filter(filter) {
165        return Err(MqttError::InvalidTopicFilter(filter.to_string()));
166    }
167    Ok(())
168}
169
170/// Validates a client ID and returns an error if invalid
171///
172/// # Errors
173///
174/// Returns `MqttError::InvalidClientId` if the client ID:
175/// - Contains non-alphanumeric characters
176/// - Exceeds maximum client ID length
177pub fn validate_client_id(client_id: &str) -> Result<()> {
178    if !is_valid_client_id(client_id) {
179        return Err(MqttError::InvalidClientId(client_id.to_string()));
180    }
181    Ok(())
182}
183
184/// Checks if a topic name matches a topic filter
185///
186/// # Rules:
187/// - '+' matches exactly one topic level
188/// - '#' matches any number of levels including parent level
189/// - Topic and filter level separators must match exactly
190/// - Topics starting with '$' do NOT match root-level wildcards (MQTT spec)
191#[must_use]
192pub fn topic_matches_filter(topic: &str, filter: &str) -> bool {
193    // MQTT spec: topics starting with $ do not match wildcards at root level
194    if topic.starts_with('$') && (filter.starts_with('#') || filter.starts_with('+')) {
195        return false;
196    }
197
198    if filter == "#" {
199        return true;
200    }
201
202    let topic_parts: Vec<&str> = topic.split('/').collect();
203    let filter_parts: Vec<&str> = filter.split('/').collect();
204
205    let mut t_idx = 0;
206    let mut f_idx = 0;
207
208    while t_idx < topic_parts.len() && f_idx < filter_parts.len() {
209        if filter_parts[f_idx] == "#" {
210            return true; // Multi-level wildcard matches everything remaining
211        }
212
213        if filter_parts[f_idx] != "+" && filter_parts[f_idx] != topic_parts[t_idx] {
214            return false; // Not a match
215        }
216
217        t_idx += 1;
218        f_idx += 1;
219    }
220
221    // Check if we've consumed both topic and filter
222    if t_idx == topic_parts.len() && f_idx == filter_parts.len() {
223        return true;
224    }
225
226    // Check if filter ends with # and we've consumed the topic
227    if t_idx == topic_parts.len() && f_idx == filter_parts.len() - 1 && filter_parts[f_idx] == "#" {
228        return true;
229    }
230
231    false
232}
233
234/// Trait for pluggable topic validation
235///
236/// This trait allows customization of topic validation rules beyond the standard MQTT specification.
237/// Implementations can add additional restrictions, reserved topic prefixes, or cloud provider-specific rules.
238pub trait TopicValidator: Send + Sync {
239    /// Validates a topic name for publishing
240    ///
241    /// # Arguments
242    ///
243    /// * `topic` - The topic name to validate
244    ///
245    /// # Errors
246    ///
247    /// Returns `MqttError::InvalidTopicName` if the topic is invalid
248    fn validate_topic_name(&self, topic: &str) -> Result<()>;
249
250    /// Validates a topic filter for subscriptions
251    ///
252    /// # Arguments
253    ///
254    /// * `filter` - The topic filter to validate
255    ///
256    /// # Errors
257    ///
258    /// Returns `MqttError::InvalidTopicFilter` if the filter is invalid
259    fn validate_topic_filter(&self, filter: &str) -> Result<()>;
260
261    /// Checks if a topic is reserved and should be restricted
262    ///
263    /// # Arguments
264    ///
265    /// * `topic` - The topic to check
266    ///
267    /// # Returns
268    ///
269    /// `true` if the topic is reserved and should be restricted
270    fn is_reserved_topic(&self, topic: &str) -> bool;
271
272    /// Gets a human-readable description of the validator
273    fn description(&self) -> &'static str;
274}
275
276/// Standard MQTT specification validator
277///
278/// This validator implements the basic MQTT v5.0 specification rules for topic names and filters.
279#[derive(Debug, Clone, Default)]
280pub struct StandardValidator;
281
282impl TopicValidator for StandardValidator {
283    fn validate_topic_name(&self, topic: &str) -> Result<()> {
284        validate_topic_name(topic)
285    }
286
287    fn validate_topic_filter(&self, filter: &str) -> Result<()> {
288        validate_topic_filter(filter)
289    }
290
291    fn is_reserved_topic(&self, _topic: &str) -> bool {
292        // Standard MQTT has no reserved topics
293        false
294    }
295
296    fn description(&self) -> &'static str {
297        "Standard MQTT v5.0 specification validator"
298    }
299}
300
301/// Restrictive validator with additional constraints
302///
303/// This validator extends the standard MQTT rules with additional restrictions
304/// such as reserved topic prefixes, maximum topic levels, and custom character sets.
305#[derive(Debug, Clone, Default)]
306pub struct RestrictiveValidator {
307    /// Reserved topic prefixes that should be rejected
308    pub reserved_prefixes: Vec<String>,
309    /// Maximum number of topic levels (separated by '/')
310    pub max_levels: Option<usize>,
311    /// Maximum topic length (overrides MQTT spec if smaller)
312    pub max_topic_length: Option<usize>,
313    /// Prohibited characters beyond MQTT spec requirements
314    pub prohibited_chars: Vec<char>,
315}
316
317impl RestrictiveValidator {
318    /// Creates a new restrictive validator
319    #[must_use]
320    pub fn new() -> Self {
321        Self::default()
322    }
323
324    /// Adds a reserved topic prefix
325    #[must_use]
326    pub fn with_reserved_prefix(mut self, prefix: impl Into<String>) -> Self {
327        self.reserved_prefixes.push(prefix.into());
328        self
329    }
330
331    /// Sets the maximum number of topic levels
332    #[must_use]
333    pub fn with_max_levels(mut self, max_levels: usize) -> Self {
334        self.max_levels = Some(max_levels);
335        self
336    }
337
338    /// Sets the maximum topic length
339    #[must_use]
340    pub fn with_max_topic_length(mut self, max_length: usize) -> Self {
341        self.max_topic_length = Some(max_length);
342        self
343    }
344
345    /// Adds a prohibited character
346    #[must_use]
347    pub fn with_prohibited_char(mut self, ch: char) -> Self {
348        self.prohibited_chars.push(ch);
349        self
350    }
351
352    /// Checks if topic violates additional restrictions
353    fn check_additional_restrictions(&self, topic: &str) -> Result<()> {
354        // Check reserved prefixes
355        for prefix in &self.reserved_prefixes {
356            if topic.starts_with(prefix) {
357                return Err(MqttError::InvalidTopicName(format!(
358                    "Topic '{topic}' uses reserved prefix '{prefix}'"
359                )));
360            }
361        }
362
363        // Check maximum levels
364        if let Some(max_levels) = self.max_levels {
365            let level_count = topic.split('/').count();
366            if level_count > max_levels {
367                return Err(MqttError::InvalidTopicName(format!(
368                    "Topic '{topic}' has {level_count} levels, maximum allowed is {max_levels}"
369                )));
370            }
371        }
372
373        // Check maximum length
374        if let Some(max_length) = self.max_topic_length {
375            if topic.len() > max_length {
376                return Err(MqttError::InvalidTopicName(format!(
377                    "Topic '{}' length {} exceeds maximum {}",
378                    topic,
379                    topic.len(),
380                    max_length
381                )));
382            }
383        }
384
385        // Check prohibited characters
386        for &prohibited_char in &self.prohibited_chars {
387            if topic.contains(prohibited_char) {
388                return Err(MqttError::InvalidTopicName(format!(
389                    "Topic '{topic}' contains prohibited character '{prohibited_char}'"
390                )));
391            }
392        }
393
394        Ok(())
395    }
396}
397
398impl TopicValidator for RestrictiveValidator {
399    fn validate_topic_name(&self, topic: &str) -> Result<()> {
400        // First apply standard validation
401        validate_topic_name(topic)?;
402        // Then apply additional restrictions
403        self.check_additional_restrictions(topic)
404    }
405
406    fn validate_topic_filter(&self, filter: &str) -> Result<()> {
407        // First apply standard validation
408        validate_topic_filter(filter)?;
409        // Then apply additional restrictions (but allow wildcards)
410        // Note: We don't apply all restrictions to filters since they may contain wildcards
411
412        // Check reserved prefixes
413        for prefix in &self.reserved_prefixes {
414            if filter.starts_with(prefix) && !filter.contains('+') && !filter.contains('#') {
415                return Err(MqttError::InvalidTopicFilter(format!(
416                    "Topic filter '{filter}' uses reserved prefix '{prefix}'"
417                )));
418            }
419        }
420
421        Ok(())
422    }
423
424    fn is_reserved_topic(&self, topic: &str) -> bool {
425        self.reserved_prefixes
426            .iter()
427            .any(|prefix| topic.starts_with(prefix))
428    }
429
430    fn description(&self) -> &'static str {
431        "Restrictive validator with additional constraints"
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn test_valid_topic_names() {
441        assert!(is_valid_topic_name("sport/tennis"));
442        assert!(is_valid_topic_name("sport/tennis/player1"));
443        assert!(is_valid_topic_name("home/temperature"));
444        assert!(is_valid_topic_name("/"));
445        assert!(is_valid_topic_name("a"));
446    }
447
448    #[test]
449    fn test_invalid_topic_names() {
450        assert!(!is_valid_topic_name(""));
451        assert!(!is_valid_topic_name("sport/+/player"));
452        assert!(!is_valid_topic_name("sport/tennis/#"));
453        assert!(!is_valid_topic_name("home\0temperature"));
454
455        let too_long = "a".repeat(crate::constants::limits::MAX_BINARY_LENGTH as usize + 1);
456        assert!(!is_valid_topic_name(&too_long));
457    }
458
459    #[test]
460    fn test_valid_topic_filters() {
461        assert!(is_valid_topic_filter("sport/tennis"));
462        assert!(is_valid_topic_filter("sport/+/player"));
463        assert!(is_valid_topic_filter("sport/tennis/#"));
464        assert!(is_valid_topic_filter("#"));
465        assert!(is_valid_topic_filter("+"));
466        assert!(is_valid_topic_filter("+/tennis/#"));
467        assert!(is_valid_topic_filter("sport/+"));
468    }
469
470    #[test]
471    fn test_invalid_topic_filters() {
472        assert!(!is_valid_topic_filter(""));
473        assert!(!is_valid_topic_filter("sport/tennis#"));
474        assert!(!is_valid_topic_filter("sport/tennis/#/ranking"));
475        assert!(!is_valid_topic_filter("sport+"));
476        assert!(!is_valid_topic_filter("sport/+tennis"));
477        assert!(!is_valid_topic_filter("home\0temperature"));
478    }
479
480    #[test]
481    fn test_valid_client_ids() {
482        assert!(is_valid_client_id(""));
483        assert!(is_valid_client_id("client123"));
484        assert!(is_valid_client_id("MyClient"));
485        assert!(is_valid_client_id("123456789012345678901234"));
486        assert!(is_valid_client_id("a1b2c3d4e5f6"));
487    }
488
489    #[test]
490    fn test_invalid_client_ids() {
491        assert!(!is_valid_client_id("client-123"));
492        assert!(!is_valid_client_id("client.123"));
493        assert!(!is_valid_client_id("client 123"));
494        assert!(!is_valid_client_id("client@123"));
495
496        let too_long = "a".repeat(crate::constants::limits::MAX_CLIENT_ID_LENGTH + 1);
497        assert!(!is_valid_client_id(&too_long));
498    }
499
500    #[test]
501    fn test_path_safe_client_ids_valid() {
502        assert!(is_path_safe_client_id(""));
503        assert!(is_path_safe_client_id("client123"));
504        assert!(is_path_safe_client_id("my-device-001"));
505        assert!(is_path_safe_client_id("sensor_node.5"));
506        assert!(is_path_safe_client_id("client@home"));
507        assert!(is_path_safe_client_id("device 1"));
508    }
509
510    #[test]
511    fn test_path_safe_client_ids_rejects_traversal() {
512        assert!(!is_path_safe_client_id("."));
513        assert!(!is_path_safe_client_id(".."));
514        assert!(!is_path_safe_client_id("../etc"));
515        assert!(!is_path_safe_client_id("foo/../../etc"));
516        assert!(!is_path_safe_client_id("a/../b"));
517        assert!(!is_path_safe_client_id("foo/bar"));
518        assert!(!is_path_safe_client_id("foo\\bar"));
519        assert!(!is_path_safe_client_id("/etc/passwd"));
520        assert!(!is_path_safe_client_id("client\0id"));
521        assert!(!is_path_safe_client_id("client\x01id"));
522
523        let too_long = "a".repeat(crate::constants::limits::MAX_CLIENT_ID_LENGTH + 1);
524        assert!(!is_path_safe_client_id(&too_long));
525    }
526
527    #[test]
528    fn test_topic_matches_filter() {
529        // Exact matches
530        assert!(topic_matches_filter("sport/tennis", "sport/tennis"));
531
532        // Single-level wildcard
533        assert!(topic_matches_filter("sport/tennis", "sport/+"));
534        assert!(topic_matches_filter(
535            "sport/tennis/player1",
536            "sport/+/player1"
537        ));
538        assert!(topic_matches_filter(
539            "sport/tennis/player1",
540            "sport/tennis/+"
541        ));
542        assert!(!topic_matches_filter("sport/tennis/player1", "sport/+"));
543
544        // Multi-level wildcard
545        assert!(topic_matches_filter("sport/tennis", "sport/#"));
546        assert!(topic_matches_filter("sport/tennis/player1", "sport/#"));
547        assert!(topic_matches_filter(
548            "sport/tennis/player1/ranking",
549            "sport/#"
550        ));
551        assert!(topic_matches_filter("sport", "sport/#"));
552        assert!(topic_matches_filter("anything", "#"));
553        assert!(topic_matches_filter("sport/tennis", "#"));
554
555        // $ prefix topics - MQTT spec compliant behavior
556        assert!(!topic_matches_filter("$SYS/broker/uptime", "#"));
557        assert!(!topic_matches_filter(
558            "$SYS/broker/uptime",
559            "+/broker/uptime"
560        ));
561        assert!(!topic_matches_filter("$data/temp", "+/temp"));
562        assert!(topic_matches_filter("$SYS/broker/uptime", "$SYS/#"));
563        assert!(topic_matches_filter("$SYS/broker/uptime", "$SYS/+/uptime"));
564
565        // Non-matches
566        assert!(!topic_matches_filter("sport/tennis", "sport/football"));
567        assert!(!topic_matches_filter("sport", "sport/tennis"));
568        assert!(!topic_matches_filter(
569            "sport/tennis/player1",
570            "sport/tennis"
571        ));
572    }
573}