use std::collections::HashMap;
use azure_iot_operations_mqtt::control_packet::{TopicFilter, TopicName};
use regex::Regex;
pub const WILDCARD: &str = "+";
#[derive(thiserror::Error, Debug)]
pub struct TopicPatternError {
msg: Option<String>,
kind: TopicPatternErrorKind,
}
impl TopicPatternError {
#[must_use]
pub fn kind(&self) -> &TopicPatternErrorKind {
&self.kind
}
}
impl std::fmt::Display for TopicPatternError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(msg) = &self.msg {
write!(f, "{} - {}", self.kind, msg)
} else {
write!(f, "{}", self.kind)
}
}
}
impl From<azure_iot_operations_mqtt::error::TopicError> for TopicPatternError {
fn from(value: azure_iot_operations_mqtt::error::TopicError) -> Self {
TopicPatternError {
msg: Some(value.to_string()),
kind: TopicPatternErrorKind::Pattern(value.to_string()),
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum TopicPatternErrorKind {
#[error("Invalid topic pattern: {0}")]
Pattern(String),
#[error("Share name '{0}' is invalid")]
ShareName(String),
#[error("Topic namespace '{0}' is invalid")]
Namespace(String),
#[error("Token '{0}' replacement value '{1}' is invalid")]
TokenReplacement(String, String),
}
#[must_use]
pub fn contains_invalid_char(s: &str) -> bool {
s.chars().any(|c| {
!c.is_ascii() || !('!'..='~').contains(&c) || c == '+' || c == '#' || c == '{' || c == '}'
})
}
#[must_use]
pub fn is_valid_replacement(s: &str) -> bool {
!(s.is_empty()
|| contains_invalid_char(s)
|| s.starts_with('/')
|| s.ends_with('/')
|| s.contains("//"))
}
#[derive(Debug)]
pub struct TopicPattern {
static_pattern: String,
dynamic_pattern: String,
pattern_regex: Regex,
share_name: Option<String>,
namespace_prefix_len: usize,
}
impl TopicPattern {
pub fn new<'a>(
pattern: &'a str,
share_name: Option<String>,
topic_namespace: Option<&str>,
topic_token_map: &'a HashMap<String, String>,
) -> Result<Self, TopicPatternError> {
if pattern.trim().is_empty() {
return Err(TopicPatternError {
msg: Some("Pattern is empty".to_string()),
kind: TopicPatternErrorKind::Pattern(pattern.to_string()),
});
}
if pattern.starts_with('$') {
return Err(TopicPatternError {
msg: Some("Pattern must not start with '$'".to_string()),
kind: TopicPatternErrorKind::Pattern(pattern.to_string()),
});
}
if let Some(share_name) = &share_name
&& (share_name.trim().is_empty()
|| contains_invalid_char(share_name)
|| share_name.contains('/'))
{
return Err(TopicPatternError {
msg: None,
kind: TopicPatternErrorKind::ShareName(share_name.clone()),
});
}
let empty_level_regex =
Regex::new(r"((^\s*/)|(/\s*/)|(/\s*$))").expect("Static regex string should not fail");
if empty_level_regex.is_match(pattern) {
return Err(TopicPatternError {
msg: Some("Contains empty level(s)".to_string()),
kind: TopicPatternErrorKind::Pattern(pattern.to_string()),
});
}
let mut acc_pattern = String::new();
let mut namespace_prefix_len = 0;
if let Some(topic_namespace) = topic_namespace {
if !is_valid_replacement(topic_namespace) {
return Err(TopicPatternError {
msg: None,
kind: TopicPatternErrorKind::Namespace(topic_namespace.to_string()),
});
}
acc_pattern.push_str(topic_namespace);
acc_pattern.push('/');
namespace_prefix_len = topic_namespace.len() + 1; }
let pattern_regex =
Regex::new(r"(\{[^}]+\})").expect("Static regex string should not fail");
let invalid_regex =
Regex::new(r"([^\x21-\x7E]|[+#{}])").expect("Static regex string should not fail");
let mut last_match = 0;
let mut last_end_index = 0;
for caps in pattern_regex.captures_iter(pattern) {
let token_capture = caps.get(0).unwrap();
let token_with_braces = token_capture.as_str();
let token_without_braces = &token_with_braces[1..token_with_braces.len() - 1];
if token_without_braces.trim().is_empty() {
return Err(TopicPatternError {
msg: Some("Contains empty token".to_string()),
kind: TopicPatternErrorKind::Pattern(pattern.to_string()),
});
}
if last_end_index != 0 && last_end_index == token_capture.start() {
return Err(TopicPatternError {
msg: Some("Contains adjacent tokens".to_string()),
kind: TopicPatternErrorKind::Pattern(pattern.to_string()),
});
}
last_end_index = token_capture.end();
let acc = &pattern[last_match..token_capture.start()];
if invalid_regex.is_match(acc) {
return Err(TopicPatternError {
msg: Some("Contains invalid characters".to_string()),
kind: TopicPatternErrorKind::Pattern(pattern.to_string()),
});
}
acc_pattern.push_str(acc);
if invalid_regex.is_match(token_without_braces) || token_without_braces.contains('/') {
return Err(TopicPatternError {
msg: Some(format!(
"Contains invalid characters in token {token_without_braces}"
)),
kind: TopicPatternErrorKind::Pattern(pattern.to_string()),
});
}
if let Some(val) = topic_token_map.get(token_without_braces) {
if !is_valid_replacement(val) {
return Err(TopicPatternError {
msg: None,
kind: TopicPatternErrorKind::TokenReplacement(
token_without_braces.to_string(),
val.clone(),
),
});
}
acc_pattern.push_str(val);
} else {
acc_pattern.push_str(token_with_braces);
}
last_match = token_capture.end();
}
let acc = &pattern[last_match..];
if invalid_regex.is_match(acc) {
return Err(TopicPatternError {
msg: Some("Contains invalid characters".to_string()),
kind: TopicPatternErrorKind::Pattern(pattern.to_string()),
});
}
acc_pattern.push_str(acc);
Ok(TopicPattern {
static_pattern: pattern.to_string(),
dynamic_pattern: acc_pattern,
pattern_regex,
share_name,
namespace_prefix_len,
})
}
pub fn as_subscribe_topic(&self) -> Result<TopicFilter, TopicPatternError> {
let mut topic = self
.pattern_regex
.replace_all(&self.dynamic_pattern, WILDCARD)
.to_string();
if let Some(share_name) = &self.share_name {
topic = format!("$share/{share_name}/{topic}");
}
Ok(TopicFilter::new(&topic)?)
}
pub fn as_publish_topic(
&self,
tokens: &HashMap<String, String>,
) -> Result<TopicName, TopicPatternError> {
let mut publish_topic = String::with_capacity(self.dynamic_pattern.len());
let mut last_match = 0;
for caps in self.pattern_regex.captures_iter(&self.dynamic_pattern) {
let key_cap = caps.get(0).unwrap();
let key = &key_cap.as_str()[1..key_cap.as_str().len() - 1];
publish_topic.push_str(&self.dynamic_pattern[last_match..key_cap.start()]);
if let Some(val) = tokens.get(key) {
if !is_valid_replacement(val) {
return Err(TopicPatternError {
msg: None,
kind: TopicPatternErrorKind::TokenReplacement(key.to_string(), val.clone()),
});
}
publish_topic.push_str(val);
} else {
return Err(TopicPatternError {
msg: None,
kind: TopicPatternErrorKind::TokenReplacement(key.to_string(), String::new()),
});
}
last_match = key_cap.end();
}
publish_topic.push_str(&self.dynamic_pattern[last_match..]);
Ok(TopicName::new(&publish_topic)?)
}
#[must_use]
pub fn parse_tokens(&self, topic: &str) -> HashMap<String, String> {
let mut tokens = HashMap::new();
let topic_ref = if topic.len() >= self.namespace_prefix_len {
&topic[self.namespace_prefix_len..]
} else {
return tokens;
};
let mut topic_ref = topic_ref;
let mut last_token_end = 0;
for find in self.pattern_regex.find_iter(&self.static_pattern) {
let token_start = find.start();
let token_end = find.end();
let value_start = token_start - last_token_end;
last_token_end = token_end + 1;
topic_ref = &topic_ref[value_start..];
let (value, rest) = topic_ref.split_once('/').unwrap_or((topic_ref, ""));
topic_ref = rest;
tokens.insert(
find.as_str()[1..find.as_str().len() - 1].to_string(), value.to_string(),
);
}
tokens
}
}
#[cfg(test)]
mod tests {
use test_case::test_case;
use super::*;
fn create_topic_tokens() -> HashMap<String, String> {
HashMap::from([
("testToken1".to_string(), "testRepl1".to_string()),
("testToken2".to_string(), "testRepl2".to_string()),
("testToken3".to_string(), "testRepl3".to_string()),
])
}
#[test_case("test", "test"; "no token")]
#[test_case("test/test", "test/test"; "no token multiple levels")]
#[test_case("{wildToken}", "{wildToken}"; "only wildcard")]
#[test_case("{testToken1}", "testRepl1"; "only token")]
#[test_case("test/{testToken1}", "test/testRepl1"; "token at end")]
#[test_case("{testToken1}/test", "testRepl1/test"; "token at start")]
#[test_case("test/{testToken1}/test", "test/testRepl1/test"; "token in middle")]
#[test_case("test/{testToken1}/test/{testToken1}", "test/testRepl1/test/testRepl1"; "multiple identical tokens")]
#[test_case("{wildToken}/{testToken1}", "{wildToken}/testRepl1"; "wildcard token")]
#[test_case("test/{testToken1}/{wildToken}", "test/testRepl1/{wildToken}"; "wildcard token at end")]
#[test_case("{wildToken}/test/{testToken1}", "{wildToken}/test/testRepl1"; "wildcard token at start")]
#[test_case("test/{testToken1}/{wildToken}/test", "test/testRepl1/{wildToken}/test"; "wildcard token in middle")]
#[test_case("test/{testToken1}/{testToken2}/{testToken3}", "test/testRepl1/testRepl2/testRepl3"; "multiple varied tokens")]
fn test_topic_pattern_new_pattern_valid(pattern: &str, result: &str) {
let pattern = TopicPattern::new(pattern, None, None, &create_topic_tokens()).unwrap();
assert_eq!(pattern.dynamic_pattern, result);
}
#[test_case(""; "empty")]
#[test_case(" "; "whitespace")]
#[test_case("$invalidPattern/{testToken1}"; "starts with dollar")]
#[test_case("/invalidPattern/{testToken1}"; "starts with slash")]
#[test_case("{testToken1}/invalidPattern/"; "ends with slash")]
#[test_case("invalid//Pattern/{testToken1}"; "contains double slash")]
#[test_case(" /invalidPattern/{testToken1}"; "starts with whitespace")]
#[test_case("{testToken1}/invalidPattern/ "; "ends with whitespace")]
#[test_case("invalidPattern/ /invalidPattern/{testToken1}"; "level contains only whitespace")]
#[test_case("invalidPattern/invalid Pattern/invalidPattern/{testToken1}"; "level contains whitespace")]
#[test_case("invalidPattern/invalid+Pattern/invalidPattern/{testToken1}"; "level contains plus")]
#[test_case("invalidPattern/invalid#Pattern/invalidPattern/{testToken1}"; "level contains hash")]
#[test_case("invalidPattern/invalid}Pattern/invalidPattern/{testToken1}"; "level contains close brace")]
#[test_case("invalidPattern/invalid\u{0000}Pattern/invalidPattern/{testToken1}"; "level contains non-ASCII")]
#[test_case("invalidPattern/{testToken1}/invalid\u{0000}Pattern/invalidPattern/{testToken2}"; "level contains non-ASCII varied token")]
#[test_case("{testToken1}{testToken1}"; "adjacent tokens")]
#[test_case("{testToken1} {testToken1}"; "adjacent spaced tokens")]
#[test_case("{testToken1}{}"; "one adjacent empty")]
#[test_case("{}{}"; "two adjacent empty")]
#[test_case("test/{testToken1}}"; "curly brace end")]
fn test_topic_pattern_new_pattern_invalid(pattern: &str) {
let err = TopicPattern::new(pattern, None, None, &create_topic_tokens()).unwrap_err();
assert!(matches!(err.kind(), TopicPatternErrorKind::Pattern(p) if p == pattern));
}
#[test_case("validNamespace"; "single level")]
#[test_case("validNamespace/validNamespace"; "multiple levels")]
fn test_topic_pattern_new_pattern_valid_topic_namespace(topic_namespace: &str) {
let pattern = "test/{testToken1}";
TopicPattern::new(pattern, None, Some(topic_namespace), &create_topic_tokens()).unwrap();
}
#[test_case(""; "empty")]
#[test_case(" "; "whitespace")]
#[test_case("invalid Namespace"; "contains space")]
#[test_case("invalid+Namespace"; "contains plus")]
#[test_case("invalid#Namespace"; "contains hash")]
#[test_case("invalid{Namespace"; "contains open brace")]
#[test_case("invalid}Namespace"; "contains close brace")]
#[test_case("invalid\u{0000}Namespace"; "contains non-ASCII")]
#[test_case("/invalidNamespace"; "namespace starts with slash")]
#[test_case("invalidNamespace/"; "namespace ends with slash")]
fn test_topic_pattern_new_pattern_invalid_topic_namespace(topic_namespace: &str) {
let pattern = "test/{testToken1}";
let err = TopicPattern::new(pattern, None, Some(topic_namespace), &create_topic_tokens())
.unwrap_err();
assert!(matches!(err.kind(), TopicPatternErrorKind::Namespace(n) if n == topic_namespace));
}
#[test_case("test/{{testToken1}"; "open brace")]
#[test_case("test/{test+Token}"; "plus")]
#[test_case("test/{test#Token}"; "hash")]
#[test_case("test/{test/Token}"; "slash")]
#[test_case("test/{test\u{0000}Token}"; "non-ASCII")]
fn test_topic_pattern_new_pattern_invalid_token(pattern: &str) {
let err = TopicPattern::new(pattern, None, None, &HashMap::new()).unwrap_err();
assert!(matches!(err.kind(), TopicPatternErrorKind::Pattern(p) if p == pattern));
}
#[test_case("invalid replacement"; "replacement contains space")]
#[test_case("invalid+replacement"; "replacement contains plus")]
#[test_case("invalid#replacement"; "replacement contains hash")]
#[test_case("invalid{replacement"; "replacement contains open brace")]
#[test_case("invalid}replacement"; "replacement contains close brace")]
#[test_case("invalid//replacement"; "replacement contains double slash")]
#[test_case("invalid\u{0000}replacement"; "replacement contains non ASCII character")]
#[test_case("/invalidReplacement"; "replacement starts with slash")]
#[test_case("invalidReplacement/"; "replacement ends with slash")]
#[test_case(""; "replacement is empty")]
#[test_case(" "; "replacement contains only space")]
fn test_topic_pattern_new_pattern_invalid_replacement(replacement: &str) {
let pattern = "test/{testToken}/test";
let err = TopicPattern::new(
pattern,
None,
None,
&HashMap::from([("testToken".to_string(), replacement.to_string())]),
)
.unwrap_err();
assert!(
matches!(err.kind(), TopicPatternErrorKind::TokenReplacement(t, r) if t == "testToken" && r == replacement)
);
}
#[test_case("test", "test"; "no token")]
#[test_case("{wildToken}", "+"; "single token")]
#[test_case("{wildToken}/test", "+/test"; "token at start")]
#[test_case("test/{wildToken}", "test/+"; "token at end")]
#[test_case("test/{wildToken}/test", "test/+/test"; "token in middle")]
#[test_case("{wildToken}/{wildToken}", "+/+"; "multiple tokens")]
#[test_case("{wildToken}/test/{wildToken}", "+/test/+"; "token at start and end")]
#[test_case("{wildToken1}/{wildToken2}", "+/+"; "multiple wildcards")]
fn test_topic_pattern_as_subscribe_topic(pattern: &str, result: &str) {
let pattern = TopicPattern::new(pattern, None, None, &HashMap::new()).unwrap();
assert_eq!(pattern.as_subscribe_topic().unwrap().as_str(), result);
}
#[test_case("invalid ShareName"; "contains space")]
#[test_case("invalid+ShareName"; "contains plus")]
#[test_case("invalid#ShareName"; "contains hash")]
#[test_case("invalid{ShareName"; "contains open brace")]
#[test_case("invalid}ShareName"; "contains close brace")]
#[test_case("invalid/ShareName"; "contains slash")]
#[test_case("invalid\u{0000}ShareName"; "contains non-ASCII")]
fn test_topic_pattern_new_pattern_invalid_share_name(share_name: &str) {
let err = TopicPattern::new("test", Some(share_name.to_string()), None, &HashMap::new())
.unwrap_err();
assert!(matches!(err.kind(), TopicPatternErrorKind::ShareName(s) if s == share_name));
}
#[test]
fn test_topic_pattern_methods_with_share_name() {
let share_name = "validShareName";
let pattern = "test/{testToken1}";
let result = "$share/validShareName/test/testRepl1";
let pattern = TopicPattern::new(
pattern,
Some(share_name.to_string()),
None,
&create_topic_tokens(),
)
.unwrap();
assert_eq!(pattern.as_subscribe_topic().unwrap().as_str(), result);
assert_eq!(
pattern.as_publish_topic(&HashMap::new()).unwrap().as_str(),
"test/testRepl1"
);
}
#[test_case("test", &HashMap::new(), "test"; "no token")]
#[test_case("{testToken}", &HashMap::from([("testToken".to_string(), "testRepl".to_string())]), "testRepl"; "single token")]
#[test_case("{testToken}", &HashMap::from([("testToken".to_string(), "testReplLonger".to_string())]), "testReplLonger"; "single token long replacement")]
#[test_case("{testToken}/test", &HashMap::from([("testToken".to_string(), "testRepl".to_string())]), "testRepl/test"; "token at start")]
#[test_case("test/{testToken}", &HashMap::from([("testToken".to_string(), "testRepl".to_string())]), "test/testRepl"; "token at end")]
#[test_case("test/{testToken}/test", &HashMap::from([("testToken".to_string(), "testRepl".to_string())]), "test/testRepl/test"; "token in middle")]
#[test_case("{testToken1}/{testToken2}", &HashMap::from([("testToken1".to_string(), "testRepl1".to_string()), ("testToken2".to_string(), "testRepl2".to_string())]), "testRepl1/testRepl2"; "multiple tokens")]
fn test_topic_pattern_as_publish_topic_valid(
pattern: &str,
tokens: &HashMap<String, String>,
result: &str,
) {
let pattern = TopicPattern::new(pattern, None, None, tokens).unwrap();
assert_eq!(pattern.as_publish_topic(tokens).unwrap().as_str(), result);
}
#[test_case("{testToken}", &HashMap::new(), "testToken", ""; "no replacement")]
#[test_case("{testToken}", &HashMap::from([("testToken".to_string(), "invalid Replacement".to_string())]), "testToken", "invalid Replacement"; "replacement contains space")]
#[test_case("{testToken}", &HashMap::from([("testToken".to_string(), "invalid+Replacement".to_string())]), "testToken", "invalid+Replacement"; "replacement contains plus")]
#[test_case("{testToken}", &HashMap::from([("testToken".to_string(), "invalid#Replacement".to_string())]), "testToken", "invalid#Replacement"; "replacement contains hash")]
#[test_case("{testToken}", &HashMap::from([("testToken".to_string(), "invalid{Replacement".to_string())]), "testToken", "invalid{Replacement"; "replacement contains open brace")]
#[test_case("{testToken}", &HashMap::from([("testToken".to_string(), "invalid}Replacement".to_string())]), "testToken", "invalid}Replacement"; "replacement contains close brace")]
#[test_case("{testToken}", &HashMap::from([("testToken".to_string(), "invalid//Replacement".to_string())]), "testToken", "invalid//Replacement"; "replacement contains double slash")]
#[test_case("{testToken}", &HashMap::from([("testToken".to_string(), "invalid\u{0000}Replacement".to_string())]), "testToken", "invalid\u{0000}Replacement"; "replacement contains non ASCII character")]
#[test_case("{testToken}", &HashMap::from([("testToken".to_string(), "/invalidReplacement".to_string())]), "testToken", "/invalidReplacement"; "replacement starts with slash")]
#[test_case("{testToken}", &HashMap::from([("testToken".to_string(), "invalidReplacement/".to_string())]), "testToken", "invalidReplacement/"; "replacement ends with slash")]
#[test_case("{testToken}", &HashMap::from([("testToken".to_string(), String::new())]), "testToken", ""; "replacement is empty")]
#[test_case("{testToken}", &HashMap::from([("testToken".to_string(), " ".to_string())]), "testToken", " "; "replacement contains only space")]
fn test_topic_pattern_as_publish_topic_invalid(
pattern: &str,
tokens: &HashMap<String, String>,
expected_token: &str,
expected_replacement: &str,
) {
let pattern = TopicPattern::new(pattern, None, None, &HashMap::new()).unwrap();
let err = pattern.as_publish_topic(tokens).unwrap_err();
assert!(
matches!(err.kind(), TopicPatternErrorKind::TokenReplacement(t, r) if t == expected_token && r == expected_replacement)
);
}
#[test_case("test", "test", &HashMap::new(); "no token")]
#[test_case("{testToken}", "testRepl", &HashMap::from([("testToken".to_string(), "testRepl".to_string())]); "single token")]
#[test_case("{testToken}/test", "testRepl/test", &HashMap::from([("testToken".to_string(), "testRepl".to_string())]); "token at start")]
#[test_case("test/{testToken}", "test/testRepl", &HashMap::from([("testToken".to_string(), "testRepl".to_string())]); "token at end")]
#[test_case("test/{testToken}/test", "test/testRepl/test", &HashMap::from([("testToken".to_string(), "testRepl".to_string())]); "token in middle")]
#[test_case("{testToken1}/{testToken2}", "testRepl1/testRepl2", &HashMap::from([("testToken1".to_string(), "testRepl1".to_string()),("testToken2".to_string(), "testRepl2".to_string())]); "multiple tokens")]
fn test_topic_pattern_parse_tokens(
pattern: &str,
topic: &str,
result: &HashMap<String, String>,
) {
let pattern = TopicPattern::new(pattern, None, None, &HashMap::new()).unwrap();
assert_eq!(pattern.parse_tokens(topic), *result);
}
#[test]
fn test_topic_pattern_parse_tokens_with_topic_namespace() {
let topic = "testNamespace/testTopic/testTokenValue";
let pattern = "testTopic/{testToken}";
let namespace = "testNamespace";
let token_replacements =
HashMap::from([("testToken".to_string(), "testReplacement".to_string())]);
let topic_pattern =
TopicPattern::new(pattern, None, Some(namespace), &token_replacements).unwrap();
let parsed_tokens = topic_pattern.parse_tokens(topic);
assert_eq!(parsed_tokens.get("testToken").unwrap(), "testTokenValue");
}
}