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
112pub fn validate_topic_name(topic: &str) -> Result<()> {
122 if !is_valid_topic_name(topic) {
123 return Err(MqttError::InvalidTopicName(topic.to_string()));
124 }
125 Ok(())
126}
127
128pub fn validate_topic_filter(filter: &str) -> Result<()> {
138 if !is_valid_topic_filter(filter) {
139 return Err(MqttError::InvalidTopicFilter(filter.to_string()));
140 }
141 Ok(())
142}
143
144pub fn validate_client_id(client_id: &str) -> Result<()> {
152 if !is_valid_client_id(client_id) {
153 return Err(MqttError::InvalidClientId(client_id.to_string()));
154 }
155 Ok(())
156}
157
158#[must_use]
166pub fn topic_matches_filter(topic: &str, filter: &str) -> bool {
167 if topic.starts_with('$') && (filter.starts_with('#') || filter.starts_with('+')) {
169 return false;
170 }
171
172 if filter == "#" {
173 return true;
174 }
175
176 let topic_parts: Vec<&str> = topic.split('/').collect();
177 let filter_parts: Vec<&str> = filter.split('/').collect();
178
179 let mut t_idx = 0;
180 let mut f_idx = 0;
181
182 while t_idx < topic_parts.len() && f_idx < filter_parts.len() {
183 if filter_parts[f_idx] == "#" {
184 return true; }
186
187 if filter_parts[f_idx] != "+" && filter_parts[f_idx] != topic_parts[t_idx] {
188 return false; }
190
191 t_idx += 1;
192 f_idx += 1;
193 }
194
195 if t_idx == topic_parts.len() && f_idx == filter_parts.len() {
197 return true;
198 }
199
200 if t_idx == topic_parts.len() && f_idx == filter_parts.len() - 1 && filter_parts[f_idx] == "#" {
202 return true;
203 }
204
205 false
206}
207
208pub trait TopicValidator: Send + Sync {
213 fn validate_topic_name(&self, topic: &str) -> Result<()>;
223
224 fn validate_topic_filter(&self, filter: &str) -> Result<()>;
234
235 fn is_reserved_topic(&self, topic: &str) -> bool;
245
246 fn description(&self) -> &'static str;
248}
249
250#[derive(Debug, Clone, Default)]
254pub struct StandardValidator;
255
256impl TopicValidator for StandardValidator {
257 fn validate_topic_name(&self, topic: &str) -> Result<()> {
258 validate_topic_name(topic)
259 }
260
261 fn validate_topic_filter(&self, filter: &str) -> Result<()> {
262 validate_topic_filter(filter)
263 }
264
265 fn is_reserved_topic(&self, _topic: &str) -> bool {
266 false
268 }
269
270 fn description(&self) -> &'static str {
271 "Standard MQTT v5.0 specification validator"
272 }
273}
274
275#[derive(Debug, Clone, Default)]
280pub struct RestrictiveValidator {
281 pub reserved_prefixes: Vec<String>,
283 pub max_levels: Option<usize>,
285 pub max_topic_length: Option<usize>,
287 pub prohibited_chars: Vec<char>,
289}
290
291impl RestrictiveValidator {
292 #[must_use]
294 pub fn new() -> Self {
295 Self::default()
296 }
297
298 #[must_use]
300 pub fn with_reserved_prefix(mut self, prefix: impl Into<String>) -> Self {
301 self.reserved_prefixes.push(prefix.into());
302 self
303 }
304
305 #[must_use]
307 pub fn with_max_levels(mut self, max_levels: usize) -> Self {
308 self.max_levels = Some(max_levels);
309 self
310 }
311
312 #[must_use]
314 pub fn with_max_topic_length(mut self, max_length: usize) -> Self {
315 self.max_topic_length = Some(max_length);
316 self
317 }
318
319 #[must_use]
321 pub fn with_prohibited_char(mut self, ch: char) -> Self {
322 self.prohibited_chars.push(ch);
323 self
324 }
325
326 fn check_additional_restrictions(&self, topic: &str) -> Result<()> {
328 for prefix in &self.reserved_prefixes {
330 if topic.starts_with(prefix) {
331 return Err(MqttError::InvalidTopicName(format!(
332 "Topic '{topic}' uses reserved prefix '{prefix}'"
333 )));
334 }
335 }
336
337 if let Some(max_levels) = self.max_levels {
339 let level_count = topic.split('/').count();
340 if level_count > max_levels {
341 return Err(MqttError::InvalidTopicName(format!(
342 "Topic '{topic}' has {level_count} levels, maximum allowed is {max_levels}"
343 )));
344 }
345 }
346
347 if let Some(max_length) = self.max_topic_length {
349 if topic.len() > max_length {
350 return Err(MqttError::InvalidTopicName(format!(
351 "Topic '{}' length {} exceeds maximum {}",
352 topic,
353 topic.len(),
354 max_length
355 )));
356 }
357 }
358
359 for &prohibited_char in &self.prohibited_chars {
361 if topic.contains(prohibited_char) {
362 return Err(MqttError::InvalidTopicName(format!(
363 "Topic '{topic}' contains prohibited character '{prohibited_char}'"
364 )));
365 }
366 }
367
368 Ok(())
369 }
370}
371
372impl TopicValidator for RestrictiveValidator {
373 fn validate_topic_name(&self, topic: &str) -> Result<()> {
374 validate_topic_name(topic)?;
376 self.check_additional_restrictions(topic)
378 }
379
380 fn validate_topic_filter(&self, filter: &str) -> Result<()> {
381 validate_topic_filter(filter)?;
383 for prefix in &self.reserved_prefixes {
388 if filter.starts_with(prefix) && !filter.contains('+') && !filter.contains('#') {
389 return Err(MqttError::InvalidTopicFilter(format!(
390 "Topic filter '{filter}' uses reserved prefix '{prefix}'"
391 )));
392 }
393 }
394
395 Ok(())
396 }
397
398 fn is_reserved_topic(&self, topic: &str) -> bool {
399 self.reserved_prefixes
400 .iter()
401 .any(|prefix| topic.starts_with(prefix))
402 }
403
404 fn description(&self) -> &'static str {
405 "Restrictive validator with additional constraints"
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
414 fn test_valid_topic_names() {
415 assert!(is_valid_topic_name("sport/tennis"));
416 assert!(is_valid_topic_name("sport/tennis/player1"));
417 assert!(is_valid_topic_name("home/temperature"));
418 assert!(is_valid_topic_name("/"));
419 assert!(is_valid_topic_name("a"));
420 }
421
422 #[test]
423 fn test_invalid_topic_names() {
424 assert!(!is_valid_topic_name(""));
425 assert!(!is_valid_topic_name("sport/+/player"));
426 assert!(!is_valid_topic_name("sport/tennis/#"));
427 assert!(!is_valid_topic_name("home\0temperature"));
428
429 let too_long = "a".repeat(crate::constants::limits::MAX_BINARY_LENGTH as usize + 1);
430 assert!(!is_valid_topic_name(&too_long));
431 }
432
433 #[test]
434 fn test_valid_topic_filters() {
435 assert!(is_valid_topic_filter("sport/tennis"));
436 assert!(is_valid_topic_filter("sport/+/player"));
437 assert!(is_valid_topic_filter("sport/tennis/#"));
438 assert!(is_valid_topic_filter("#"));
439 assert!(is_valid_topic_filter("+"));
440 assert!(is_valid_topic_filter("+/tennis/#"));
441 assert!(is_valid_topic_filter("sport/+"));
442 }
443
444 #[test]
445 fn test_invalid_topic_filters() {
446 assert!(!is_valid_topic_filter(""));
447 assert!(!is_valid_topic_filter("sport/tennis#"));
448 assert!(!is_valid_topic_filter("sport/tennis/#/ranking"));
449 assert!(!is_valid_topic_filter("sport+"));
450 assert!(!is_valid_topic_filter("sport/+tennis"));
451 assert!(!is_valid_topic_filter("home\0temperature"));
452 }
453
454 #[test]
455 fn test_valid_client_ids() {
456 assert!(is_valid_client_id(""));
457 assert!(is_valid_client_id("client123"));
458 assert!(is_valid_client_id("MyClient"));
459 assert!(is_valid_client_id("123456789012345678901234"));
460 assert!(is_valid_client_id("a1b2c3d4e5f6"));
461 }
462
463 #[test]
464 fn test_invalid_client_ids() {
465 assert!(!is_valid_client_id("client-123"));
466 assert!(!is_valid_client_id("client.123"));
467 assert!(!is_valid_client_id("client 123"));
468 assert!(!is_valid_client_id("client@123"));
469
470 let too_long = "a".repeat(crate::constants::limits::MAX_CLIENT_ID_LENGTH + 1);
471 assert!(!is_valid_client_id(&too_long));
472 }
473
474 #[test]
475 fn test_topic_matches_filter() {
476 assert!(topic_matches_filter("sport/tennis", "sport/tennis"));
478
479 assert!(topic_matches_filter("sport/tennis", "sport/+"));
481 assert!(topic_matches_filter(
482 "sport/tennis/player1",
483 "sport/+/player1"
484 ));
485 assert!(topic_matches_filter(
486 "sport/tennis/player1",
487 "sport/tennis/+"
488 ));
489 assert!(!topic_matches_filter("sport/tennis/player1", "sport/+"));
490
491 assert!(topic_matches_filter("sport/tennis", "sport/#"));
493 assert!(topic_matches_filter("sport/tennis/player1", "sport/#"));
494 assert!(topic_matches_filter(
495 "sport/tennis/player1/ranking",
496 "sport/#"
497 ));
498 assert!(topic_matches_filter("sport", "sport/#"));
499 assert!(topic_matches_filter("anything", "#"));
500 assert!(topic_matches_filter("sport/tennis", "#"));
501
502 assert!(!topic_matches_filter("$SYS/broker/uptime", "#"));
504 assert!(!topic_matches_filter(
505 "$SYS/broker/uptime",
506 "+/broker/uptime"
507 ));
508 assert!(!topic_matches_filter("$data/temp", "+/temp"));
509 assert!(topic_matches_filter("$SYS/broker/uptime", "$SYS/#"));
510 assert!(topic_matches_filter("$SYS/broker/uptime", "$SYS/+/uptime"));
511
512 assert!(!topic_matches_filter("sport/tennis", "sport/football"));
514 assert!(!topic_matches_filter("sport", "sport/tennis"));
515 assert!(!topic_matches_filter(
516 "sport/tennis/player1",
517 "sport/tennis"
518 ));
519 }
520}