mqtt5_protocol/validation/
mod.rs1use crate::error::{MqttError, Result};
2
3pub mod namespace;
4
5#[must_use]
14pub fn is_valid_topic_name(topic: &str) -> bool {
15 if topic.is_empty() {
16 return false;
17 }
18
19 if topic.len() > crate::constants::limits::MAX_STRING_LENGTH as usize {
20 return false;
21 }
22
23 if topic.contains('\0') {
24 return false;
25 }
26
27 if topic.contains('+') || topic.contains('#') {
29 return false;
30 }
31
32 true
33}
34
35#[must_use]
43pub fn is_valid_topic_filter(filter: &str) -> bool {
44 if filter.is_empty() {
45 return false;
46 }
47
48 if filter.len() > crate::constants::limits::MAX_STRING_LENGTH as usize {
49 return false;
50 }
51
52 if filter.contains('\0') {
53 return false;
54 }
55
56 let parts: Vec<&str> = filter.split('/').collect();
57
58 for (i, part) in parts.iter().enumerate() {
59 if part.contains('#') {
61 if i != parts.len() - 1 {
63 return false;
64 }
65 if *part != "#" {
67 return false;
68 }
69 }
70
71 if part.contains('+') {
73 if *part != "+" {
75 return false;
76 }
77 }
78 }
79
80 true
81}
82
83#[must_use]
91pub fn is_valid_client_id(client_id: &str) -> bool {
92 if client_id.is_empty() {
93 return true; }
95
96 if client_id.len() > 23 {
97 if client_id.len() > crate::constants::limits::MAX_CLIENT_ID_LENGTH {
100 return false; }
102 }
103
104 client_id.chars().all(|c| c.is_ascii_alphanumeric())
106}
107
108pub fn validate_topic_name(topic: &str) -> Result<()> {
118 if !is_valid_topic_name(topic) {
119 return Err(MqttError::InvalidTopicName(topic.to_string()));
120 }
121 Ok(())
122}
123
124pub fn validate_topic_filter(filter: &str) -> Result<()> {
134 if !is_valid_topic_filter(filter) {
135 return Err(MqttError::InvalidTopicFilter(filter.to_string()));
136 }
137 Ok(())
138}
139
140pub fn validate_client_id(client_id: &str) -> Result<()> {
148 if !is_valid_client_id(client_id) {
149 return Err(MqttError::InvalidClientId(client_id.to_string()));
150 }
151 Ok(())
152}
153
154#[must_use]
162pub fn topic_matches_filter(topic: &str, filter: &str) -> bool {
163 if topic.starts_with('$') && (filter.starts_with('#') || filter.starts_with('+')) {
165 return false;
166 }
167
168 if filter == "#" {
169 return true;
170 }
171
172 let topic_parts: Vec<&str> = topic.split('/').collect();
173 let filter_parts: Vec<&str> = filter.split('/').collect();
174
175 let mut t_idx = 0;
176 let mut f_idx = 0;
177
178 while t_idx < topic_parts.len() && f_idx < filter_parts.len() {
179 if filter_parts[f_idx] == "#" {
180 return true; }
182
183 if filter_parts[f_idx] != "+" && filter_parts[f_idx] != topic_parts[t_idx] {
184 return false; }
186
187 t_idx += 1;
188 f_idx += 1;
189 }
190
191 if t_idx == topic_parts.len() && f_idx == filter_parts.len() {
193 return true;
194 }
195
196 if t_idx == topic_parts.len() && f_idx == filter_parts.len() - 1 && filter_parts[f_idx] == "#" {
198 return true;
199 }
200
201 false
202}
203
204#[must_use]
205pub fn parse_shared_subscription(topic_filter: &str) -> (&str, Option<&str>) {
206 if let Some(after_share) = topic_filter.strip_prefix("$share/") {
207 if let Some(slash_pos) = after_share.find('/') {
208 let group_name = &after_share[..slash_pos];
209 let actual_filter = &after_share[slash_pos + 1..];
210 return (actual_filter, Some(group_name));
211 }
212 }
213 (topic_filter, None)
214}
215
216#[must_use]
217pub fn strip_shared_subscription_prefix(topic_filter: &str) -> &str {
218 parse_shared_subscription(topic_filter).0
219}
220
221pub trait TopicValidator: Send + Sync {
226 fn validate_topic_name(&self, topic: &str) -> Result<()>;
236
237 fn validate_topic_filter(&self, filter: &str) -> Result<()>;
247
248 fn is_reserved_topic(&self, topic: &str) -> bool;
258
259 fn description(&self) -> &'static str;
261}
262
263#[derive(Debug, Clone, Default)]
267pub struct StandardValidator;
268
269impl TopicValidator for StandardValidator {
270 fn validate_topic_name(&self, topic: &str) -> Result<()> {
271 validate_topic_name(topic)
272 }
273
274 fn validate_topic_filter(&self, filter: &str) -> Result<()> {
275 validate_topic_filter(filter)
276 }
277
278 fn is_reserved_topic(&self, _topic: &str) -> bool {
279 false
281 }
282
283 fn description(&self) -> &'static str {
284 "Standard MQTT v5.0 specification validator"
285 }
286}
287
288#[derive(Debug, Clone, Default)]
293pub struct RestrictiveValidator {
294 pub reserved_prefixes: Vec<String>,
296 pub max_levels: Option<usize>,
298 pub max_topic_length: Option<usize>,
300 pub prohibited_chars: Vec<char>,
302}
303
304impl RestrictiveValidator {
305 #[must_use]
307 pub fn new() -> Self {
308 Self::default()
309 }
310
311 #[must_use]
313 pub fn with_reserved_prefix(mut self, prefix: impl Into<String>) -> Self {
314 self.reserved_prefixes.push(prefix.into());
315 self
316 }
317
318 #[must_use]
320 pub fn with_max_levels(mut self, max_levels: usize) -> Self {
321 self.max_levels = Some(max_levels);
322 self
323 }
324
325 #[must_use]
327 pub fn with_max_topic_length(mut self, max_length: usize) -> Self {
328 self.max_topic_length = Some(max_length);
329 self
330 }
331
332 #[must_use]
334 pub fn with_prohibited_char(mut self, ch: char) -> Self {
335 self.prohibited_chars.push(ch);
336 self
337 }
338
339 fn check_additional_restrictions(&self, topic: &str) -> Result<()> {
341 for prefix in &self.reserved_prefixes {
343 if topic.starts_with(prefix) {
344 return Err(MqttError::InvalidTopicName(format!(
345 "Topic '{topic}' uses reserved prefix '{prefix}'"
346 )));
347 }
348 }
349
350 if let Some(max_levels) = self.max_levels {
352 let level_count = topic.split('/').count();
353 if level_count > max_levels {
354 return Err(MqttError::InvalidTopicName(format!(
355 "Topic '{topic}' has {level_count} levels, maximum allowed is {max_levels}"
356 )));
357 }
358 }
359
360 if let Some(max_length) = self.max_topic_length {
362 if topic.len() > max_length {
363 return Err(MqttError::InvalidTopicName(format!(
364 "Topic '{}' length {} exceeds maximum {}",
365 topic,
366 topic.len(),
367 max_length
368 )));
369 }
370 }
371
372 for &prohibited_char in &self.prohibited_chars {
374 if topic.contains(prohibited_char) {
375 return Err(MqttError::InvalidTopicName(format!(
376 "Topic '{topic}' contains prohibited character '{prohibited_char}'"
377 )));
378 }
379 }
380
381 Ok(())
382 }
383}
384
385impl TopicValidator for RestrictiveValidator {
386 fn validate_topic_name(&self, topic: &str) -> Result<()> {
387 validate_topic_name(topic)?;
389 self.check_additional_restrictions(topic)
391 }
392
393 fn validate_topic_filter(&self, filter: &str) -> Result<()> {
394 validate_topic_filter(filter)?;
396 for prefix in &self.reserved_prefixes {
401 if filter.starts_with(prefix) && !filter.contains('+') && !filter.contains('#') {
402 return Err(MqttError::InvalidTopicFilter(format!(
403 "Topic filter '{filter}' uses reserved prefix '{prefix}'"
404 )));
405 }
406 }
407
408 Ok(())
409 }
410
411 fn is_reserved_topic(&self, topic: &str) -> bool {
412 self.reserved_prefixes
413 .iter()
414 .any(|prefix| topic.starts_with(prefix))
415 }
416
417 fn description(&self) -> &'static str {
418 "Restrictive validator with additional constraints"
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn test_valid_topic_names() {
428 assert!(is_valid_topic_name("sport/tennis"));
429 assert!(is_valid_topic_name("sport/tennis/player1"));
430 assert!(is_valid_topic_name("home/temperature"));
431 assert!(is_valid_topic_name("/"));
432 assert!(is_valid_topic_name("a"));
433 }
434
435 #[test]
436 fn test_invalid_topic_names() {
437 assert!(!is_valid_topic_name(""));
438 assert!(!is_valid_topic_name("sport/+/player"));
439 assert!(!is_valid_topic_name("sport/tennis/#"));
440 assert!(!is_valid_topic_name("home\0temperature"));
441
442 let too_long = "a".repeat(crate::constants::limits::MAX_BINARY_LENGTH as usize);
443 assert!(!is_valid_topic_name(&too_long));
444 }
445
446 #[test]
447 fn test_valid_topic_filters() {
448 assert!(is_valid_topic_filter("sport/tennis"));
449 assert!(is_valid_topic_filter("sport/+/player"));
450 assert!(is_valid_topic_filter("sport/tennis/#"));
451 assert!(is_valid_topic_filter("#"));
452 assert!(is_valid_topic_filter("+"));
453 assert!(is_valid_topic_filter("+/tennis/#"));
454 assert!(is_valid_topic_filter("sport/+"));
455 }
456
457 #[test]
458 fn test_invalid_topic_filters() {
459 assert!(!is_valid_topic_filter(""));
460 assert!(!is_valid_topic_filter("sport/tennis#"));
461 assert!(!is_valid_topic_filter("sport/tennis/#/ranking"));
462 assert!(!is_valid_topic_filter("sport+"));
463 assert!(!is_valid_topic_filter("sport/+tennis"));
464 assert!(!is_valid_topic_filter("home\0temperature"));
465 }
466
467 #[test]
468 fn test_valid_client_ids() {
469 assert!(is_valid_client_id(""));
470 assert!(is_valid_client_id("client123"));
471 assert!(is_valid_client_id("MyClient"));
472 assert!(is_valid_client_id("123456789012345678901234"));
473 assert!(is_valid_client_id("a1b2c3d4e5f6"));
474 }
475
476 #[test]
477 fn test_invalid_client_ids() {
478 assert!(!is_valid_client_id("client-123"));
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
483 let too_long = "a".repeat(crate::constants::limits::MAX_CLIENT_ID_LENGTH + 1);
484 assert!(!is_valid_client_id(&too_long));
485 }
486
487 #[test]
488 fn test_topic_matches_filter() {
489 assert!(topic_matches_filter("sport/tennis", "sport/tennis"));
491
492 assert!(topic_matches_filter("sport/tennis", "sport/+"));
494 assert!(topic_matches_filter(
495 "sport/tennis/player1",
496 "sport/+/player1"
497 ));
498 assert!(topic_matches_filter(
499 "sport/tennis/player1",
500 "sport/tennis/+"
501 ));
502 assert!(!topic_matches_filter("sport/tennis/player1", "sport/+"));
503
504 assert!(topic_matches_filter("sport/tennis", "sport/#"));
506 assert!(topic_matches_filter("sport/tennis/player1", "sport/#"));
507 assert!(topic_matches_filter(
508 "sport/tennis/player1/ranking",
509 "sport/#"
510 ));
511 assert!(topic_matches_filter("sport", "sport/#"));
512 assert!(topic_matches_filter("anything", "#"));
513 assert!(topic_matches_filter("sport/tennis", "#"));
514
515 assert!(!topic_matches_filter("$SYS/broker/uptime", "#"));
517 assert!(!topic_matches_filter(
518 "$SYS/broker/uptime",
519 "+/broker/uptime"
520 ));
521 assert!(!topic_matches_filter("$data/temp", "+/temp"));
522 assert!(topic_matches_filter("$SYS/broker/uptime", "$SYS/#"));
523 assert!(topic_matches_filter("$SYS/broker/uptime", "$SYS/+/uptime"));
524
525 assert!(!topic_matches_filter("sport/tennis", "sport/football"));
527 assert!(!topic_matches_filter("sport", "sport/tennis"));
528 assert!(!topic_matches_filter(
529 "sport/tennis/player1",
530 "sport/tennis"
531 ));
532 }
533
534 #[test]
535 fn test_parse_shared_subscription() {
536 assert_eq!(
537 parse_shared_subscription("$share/workers/tasks/+"),
538 ("tasks/+", Some("workers"))
539 );
540 assert_eq!(
541 parse_shared_subscription("$share/group1/sensor/temperature"),
542 ("sensor/temperature", Some("group1"))
543 );
544 assert_eq!(
545 parse_shared_subscription("$share/mygroup/#"),
546 ("#", Some("mygroup"))
547 );
548 assert_eq!(
549 parse_shared_subscription("normal/topic"),
550 ("normal/topic", None)
551 );
552 assert_eq!(parse_shared_subscription("#"), ("#", None));
553 assert_eq!(
554 parse_shared_subscription("$share/incomplete"),
555 ("$share/incomplete", None)
556 );
557 assert_eq!(parse_shared_subscription(""), ("", None));
558 }
559
560 #[test]
561 fn test_strip_shared_subscription_prefix() {
562 assert_eq!(
563 strip_shared_subscription_prefix("$share/workers/tasks/+"),
564 "tasks/+"
565 );
566 assert_eq!(
567 strip_shared_subscription_prefix("$share/group1/sensor/temp"),
568 "sensor/temp"
569 );
570 assert_eq!(
571 strip_shared_subscription_prefix("normal/topic"),
572 "normal/topic"
573 );
574 assert_eq!(strip_shared_subscription_prefix("#"), "#");
575 assert_eq!(
576 strip_shared_subscription_prefix("$share/nofilter"),
577 "$share/nofilter"
578 );
579 }
580}