mqtt5_protocol/validation/
mod.rs

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