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
204pub trait TopicValidator: Send + Sync {
209 fn validate_topic_name(&self, topic: &str) -> Result<()>;
219
220 fn validate_topic_filter(&self, filter: &str) -> Result<()>;
230
231 fn is_reserved_topic(&self, topic: &str) -> bool;
241
242 fn description(&self) -> &'static str;
244}
245
246#[derive(Debug, Clone, Default)]
250pub struct StandardValidator;
251
252impl TopicValidator for StandardValidator {
253 fn validate_topic_name(&self, topic: &str) -> Result<()> {
254 validate_topic_name(topic)
255 }
256
257 fn validate_topic_filter(&self, filter: &str) -> Result<()> {
258 validate_topic_filter(filter)
259 }
260
261 fn is_reserved_topic(&self, _topic: &str) -> bool {
262 false
264 }
265
266 fn description(&self) -> &'static str {
267 "Standard MQTT v5.0 specification validator"
268 }
269}
270
271#[derive(Debug, Clone, Default)]
276pub struct RestrictiveValidator {
277 pub reserved_prefixes: Vec<String>,
279 pub max_levels: Option<usize>,
281 pub max_topic_length: Option<usize>,
283 pub prohibited_chars: Vec<char>,
285}
286
287impl RestrictiveValidator {
288 #[must_use]
290 pub fn new() -> Self {
291 Self::default()
292 }
293
294 #[must_use]
296 pub fn with_reserved_prefix(mut self, prefix: impl Into<String>) -> Self {
297 self.reserved_prefixes.push(prefix.into());
298 self
299 }
300
301 #[must_use]
303 pub fn with_max_levels(mut self, max_levels: usize) -> Self {
304 self.max_levels = Some(max_levels);
305 self
306 }
307
308 #[must_use]
310 pub fn with_max_topic_length(mut self, max_length: usize) -> Self {
311 self.max_topic_length = Some(max_length);
312 self
313 }
314
315 #[must_use]
317 pub fn with_prohibited_char(mut self, ch: char) -> Self {
318 self.prohibited_chars.push(ch);
319 self
320 }
321
322 fn check_additional_restrictions(&self, topic: &str) -> Result<()> {
324 for prefix in &self.reserved_prefixes {
326 if topic.starts_with(prefix) {
327 return Err(MqttError::InvalidTopicName(format!(
328 "Topic '{topic}' uses reserved prefix '{prefix}'"
329 )));
330 }
331 }
332
333 if let Some(max_levels) = self.max_levels {
335 let level_count = topic.split('/').count();
336 if level_count > max_levels {
337 return Err(MqttError::InvalidTopicName(format!(
338 "Topic '{topic}' has {level_count} levels, maximum allowed is {max_levels}"
339 )));
340 }
341 }
342
343 if let Some(max_length) = self.max_topic_length {
345 if topic.len() > max_length {
346 return Err(MqttError::InvalidTopicName(format!(
347 "Topic '{}' length {} exceeds maximum {}",
348 topic,
349 topic.len(),
350 max_length
351 )));
352 }
353 }
354
355 for &prohibited_char in &self.prohibited_chars {
357 if topic.contains(prohibited_char) {
358 return Err(MqttError::InvalidTopicName(format!(
359 "Topic '{topic}' contains prohibited character '{prohibited_char}'"
360 )));
361 }
362 }
363
364 Ok(())
365 }
366}
367
368impl TopicValidator for RestrictiveValidator {
369 fn validate_topic_name(&self, topic: &str) -> Result<()> {
370 validate_topic_name(topic)?;
372 self.check_additional_restrictions(topic)
374 }
375
376 fn validate_topic_filter(&self, filter: &str) -> Result<()> {
377 validate_topic_filter(filter)?;
379 for prefix in &self.reserved_prefixes {
384 if filter.starts_with(prefix) && !filter.contains('+') && !filter.contains('#') {
385 return Err(MqttError::InvalidTopicFilter(format!(
386 "Topic filter '{filter}' uses reserved prefix '{prefix}'"
387 )));
388 }
389 }
390
391 Ok(())
392 }
393
394 fn is_reserved_topic(&self, topic: &str) -> bool {
395 self.reserved_prefixes
396 .iter()
397 .any(|prefix| topic.starts_with(prefix))
398 }
399
400 fn description(&self) -> &'static str {
401 "Restrictive validator with additional constraints"
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn test_valid_topic_names() {
411 assert!(is_valid_topic_name("sport/tennis"));
412 assert!(is_valid_topic_name("sport/tennis/player1"));
413 assert!(is_valid_topic_name("home/temperature"));
414 assert!(is_valid_topic_name("/"));
415 assert!(is_valid_topic_name("a"));
416 }
417
418 #[test]
419 fn test_invalid_topic_names() {
420 assert!(!is_valid_topic_name(""));
421 assert!(!is_valid_topic_name("sport/+/player"));
422 assert!(!is_valid_topic_name("sport/tennis/#"));
423 assert!(!is_valid_topic_name("home\0temperature"));
424
425 let too_long = "a".repeat(crate::constants::limits::MAX_BINARY_LENGTH as usize);
426 assert!(!is_valid_topic_name(&too_long));
427 }
428
429 #[test]
430 fn test_valid_topic_filters() {
431 assert!(is_valid_topic_filter("sport/tennis"));
432 assert!(is_valid_topic_filter("sport/+/player"));
433 assert!(is_valid_topic_filter("sport/tennis/#"));
434 assert!(is_valid_topic_filter("#"));
435 assert!(is_valid_topic_filter("+"));
436 assert!(is_valid_topic_filter("+/tennis/#"));
437 assert!(is_valid_topic_filter("sport/+"));
438 }
439
440 #[test]
441 fn test_invalid_topic_filters() {
442 assert!(!is_valid_topic_filter(""));
443 assert!(!is_valid_topic_filter("sport/tennis#"));
444 assert!(!is_valid_topic_filter("sport/tennis/#/ranking"));
445 assert!(!is_valid_topic_filter("sport+"));
446 assert!(!is_valid_topic_filter("sport/+tennis"));
447 assert!(!is_valid_topic_filter("home\0temperature"));
448 }
449
450 #[test]
451 fn test_valid_client_ids() {
452 assert!(is_valid_client_id(""));
453 assert!(is_valid_client_id("client123"));
454 assert!(is_valid_client_id("MyClient"));
455 assert!(is_valid_client_id("123456789012345678901234"));
456 assert!(is_valid_client_id("a1b2c3d4e5f6"));
457 }
458
459 #[test]
460 fn test_invalid_client_ids() {
461 assert!(!is_valid_client_id("client-123"));
462 assert!(!is_valid_client_id("client.123"));
463 assert!(!is_valid_client_id("client 123"));
464 assert!(!is_valid_client_id("client@123"));
465
466 let too_long = "a".repeat(crate::constants::limits::MAX_CLIENT_ID_LENGTH + 1);
467 assert!(!is_valid_client_id(&too_long));
468 }
469
470 #[test]
471 fn test_topic_matches_filter() {
472 assert!(topic_matches_filter("sport/tennis", "sport/tennis"));
474
475 assert!(topic_matches_filter("sport/tennis", "sport/+"));
477 assert!(topic_matches_filter(
478 "sport/tennis/player1",
479 "sport/+/player1"
480 ));
481 assert!(topic_matches_filter(
482 "sport/tennis/player1",
483 "sport/tennis/+"
484 ));
485 assert!(!topic_matches_filter("sport/tennis/player1", "sport/+"));
486
487 assert!(topic_matches_filter("sport/tennis", "sport/#"));
489 assert!(topic_matches_filter("sport/tennis/player1", "sport/#"));
490 assert!(topic_matches_filter(
491 "sport/tennis/player1/ranking",
492 "sport/#"
493 ));
494 assert!(topic_matches_filter("sport", "sport/#"));
495 assert!(topic_matches_filter("anything", "#"));
496 assert!(topic_matches_filter("sport/tennis", "#"));
497
498 assert!(!topic_matches_filter("$SYS/broker/uptime", "#"));
500 assert!(!topic_matches_filter(
501 "$SYS/broker/uptime",
502 "+/broker/uptime"
503 ));
504 assert!(!topic_matches_filter("$data/temp", "+/temp"));
505 assert!(topic_matches_filter("$SYS/broker/uptime", "$SYS/#"));
506 assert!(topic_matches_filter("$SYS/broker/uptime", "$SYS/+/uptime"));
507
508 assert!(!topic_matches_filter("sport/tennis", "sport/football"));
510 assert!(!topic_matches_filter("sport", "sport/tennis"));
511 assert!(!topic_matches_filter(
512 "sport/tennis/player1",
513 "sport/tennis"
514 ));
515 }
516}