mqtt5_protocol/validation/
mod.rs1use 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#[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 if topic.contains('+') || topic.contains('#') {
33 return false;
34 }
35
36 true
37}
38
39#[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 if part.contains('#') {
65 if i != parts.len() - 1 {
67 return false;
68 }
69 if *part != "#" {
71 return false;
72 }
73 }
74
75 if part.contains('+') {
77 if *part != "+" {
79 return false;
80 }
81 }
82 }
83
84 true
85}
86
87#[must_use]
95pub fn is_valid_client_id(client_id: &str) -> bool {
96 if client_id.is_empty() {
97 return true; }
99
100 if client_id.len() > 23 {
101 if client_id.len() > crate::constants::limits::MAX_CLIENT_ID_LENGTH {
104 return false; }
106 }
107
108 client_id.chars().all(|c| c.is_ascii_alphanumeric())
110}
111
112#[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
138pub 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
154pub 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
170pub 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#[must_use]
192pub fn topic_matches_filter(topic: &str, filter: &str) -> bool {
193 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; }
212
213 if filter_parts[f_idx] != "+" && filter_parts[f_idx] != topic_parts[t_idx] {
214 return false; }
216
217 t_idx += 1;
218 f_idx += 1;
219 }
220
221 if t_idx == topic_parts.len() && f_idx == filter_parts.len() {
223 return true;
224 }
225
226 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
234pub trait TopicValidator: Send + Sync {
239 fn validate_topic_name(&self, topic: &str) -> Result<()>;
249
250 fn validate_topic_filter(&self, filter: &str) -> Result<()>;
260
261 fn is_reserved_topic(&self, topic: &str) -> bool;
271
272 fn description(&self) -> &'static str;
274}
275
276#[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 false
294 }
295
296 fn description(&self) -> &'static str {
297 "Standard MQTT v5.0 specification validator"
298 }
299}
300
301#[derive(Debug, Clone, Default)]
306pub struct RestrictiveValidator {
307 pub reserved_prefixes: Vec<String>,
309 pub max_levels: Option<usize>,
311 pub max_topic_length: Option<usize>,
313 pub prohibited_chars: Vec<char>,
315}
316
317impl RestrictiveValidator {
318 #[must_use]
320 pub fn new() -> Self {
321 Self::default()
322 }
323
324 #[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 #[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 #[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 #[must_use]
347 pub fn with_prohibited_char(mut self, ch: char) -> Self {
348 self.prohibited_chars.push(ch);
349 self
350 }
351
352 fn check_additional_restrictions(&self, topic: &str) -> Result<()> {
354 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 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 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 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 validate_topic_name(topic)?;
402 self.check_additional_restrictions(topic)
404 }
405
406 fn validate_topic_filter(&self, filter: &str) -> Result<()> {
407 validate_topic_filter(filter)?;
409 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 assert!(topic_matches_filter("sport/tennis", "sport/tennis"));
531
532 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 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 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 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}