mqtt5_protocol/validation/
mod.rs1use crate::error::{MqttError, Result};
2use crate::prelude::{format, String, ToString, Vec};
3
4pub mod namespace;
5
6#[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 if topic.contains('+') || topic.contains('#') {
30 return false;
31 }
32
33 true
34}
35
36#[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 if part.contains('#') {
62 if i != parts.len() - 1 {
64 return false;
65 }
66 if *part != "#" {
68 return false;
69 }
70 }
71
72 if part.contains('+') {
74 if *part != "+" {
76 return false;
77 }
78 }
79 }
80
81 true
82}
83
84#[must_use]
92pub fn is_valid_client_id(client_id: &str) -> bool {
93 if client_id.is_empty() {
94 return true; }
96
97 if client_id.len() > 23 {
98 if client_id.len() > crate::constants::limits::MAX_CLIENT_ID_LENGTH {
101 return false; }
103 }
104
105 client_id.chars().all(|c| c.is_ascii_alphanumeric())
107}
108
109pub 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
125pub 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
141pub 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#[must_use]
163pub fn topic_matches_filter(topic: &str, filter: &str) -> bool {
164 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; }
183
184 if filter_parts[f_idx] != "+" && filter_parts[f_idx] != topic_parts[t_idx] {
185 return false; }
187
188 t_idx += 1;
189 f_idx += 1;
190 }
191
192 if t_idx == topic_parts.len() && f_idx == filter_parts.len() {
194 return true;
195 }
196
197 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
222pub trait TopicValidator: Send + Sync {
227 fn validate_topic_name(&self, topic: &str) -> Result<()>;
237
238 fn validate_topic_filter(&self, filter: &str) -> Result<()>;
248
249 fn is_reserved_topic(&self, topic: &str) -> bool;
259
260 fn description(&self) -> &'static str;
262}
263
264#[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 false
282 }
283
284 fn description(&self) -> &'static str {
285 "Standard MQTT v5.0 specification validator"
286 }
287}
288
289#[derive(Debug, Clone, Default)]
294pub struct RestrictiveValidator {
295 pub reserved_prefixes: Vec<String>,
297 pub max_levels: Option<usize>,
299 pub max_topic_length: Option<usize>,
301 pub prohibited_chars: Vec<char>,
303}
304
305impl RestrictiveValidator {
306 #[must_use]
308 pub fn new() -> Self {
309 Self::default()
310 }
311
312 #[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 #[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 #[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 #[must_use]
335 pub fn with_prohibited_char(mut self, ch: char) -> Self {
336 self.prohibited_chars.push(ch);
337 self
338 }
339
340 fn check_additional_restrictions(&self, topic: &str) -> Result<()> {
342 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 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 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 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 validate_topic_name(topic)?;
390 self.check_additional_restrictions(topic)
392 }
393
394 fn validate_topic_filter(&self, filter: &str) -> Result<()> {
395 validate_topic_filter(filter)?;
397 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 assert!(topic_matches_filter("sport/tennis", "sport/tennis"));
492
493 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 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 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 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}