mqtt5_protocol/validation/
namespace.rs

1use crate::error::{MqttError, Result};
2use crate::prelude::{format, String, ToString, Vec};
3use crate::validation::{validate_topic_filter, validate_topic_name, TopicValidator};
4
5/// Namespace-based topic validator for hierarchical topic isolation
6///
7/// This validator implements namespace-based topic validation patterns that enforce
8/// hierarchical isolation of topics, commonly used in cloud `IoT` platforms and
9/// enterprise systems.
10///
11/// The validator enforces:
12/// - Service-reserved topic prefixes (e.g., `$aws/`, `$azure/`, `$company/`)
13/// - Device-specific namespaces (e.g., `{prefix}/thing/{device}/`, `{prefix}/device/{device}/`)
14/// - System topics (e.g., `$SYS/`)
15///
16/// Examples:
17/// - AWS `IoT`: `NamespaceValidator::new("$aws", "thing")`
18/// - Azure `IoT`: `NamespaceValidator::new("$azure", "device")`
19/// - Enterprise: `NamespaceValidator::new("$company", "asset")`
20#[derive(Debug, Clone)]
21pub struct NamespaceValidator {
22    /// Service prefix for reserved topics (e.g., "$aws", "$azure", "$company")
23    pub service_prefix: String,
24    /// Device namespace identifier (e.g., "thing", "device", "asset")
25    pub device_namespace: String,
26    /// Optional device identifier for device-specific validation
27    /// If set, allows `{service_prefix}/{device_namespace}/{device_id}/*` topics
28    pub device_id: Option<String>,
29    /// Whether to allow system topics (`$SYS/*`)
30    pub allow_system_topics: bool,
31    /// Additional reserved prefixes beyond the service prefix
32    pub additional_reserved_prefixes: Vec<String>,
33}
34
35impl NamespaceValidator {
36    /// Creates a new namespace validator
37    ///
38    /// # Arguments
39    /// * `service_prefix` - The service prefix (e.g., "$aws", "$azure")
40    /// * `device_namespace` - The device namespace pattern (e.g., "thing", "device")
41    #[must_use]
42    pub fn new(service_prefix: impl Into<String>, device_namespace: impl Into<String>) -> Self {
43        Self {
44            service_prefix: service_prefix.into(),
45            device_namespace: device_namespace.into(),
46            device_id: None,
47            allow_system_topics: false,
48            additional_reserved_prefixes: Vec::new(),
49        }
50    }
51
52    /// Creates a validator configured for AWS `IoT` Core
53    #[must_use]
54    pub fn aws_iot() -> Self {
55        Self::new("$aws", "things")
56    }
57
58    /// Creates a validator configured for Azure `IoT` Hub
59    #[must_use]
60    pub fn azure_iot() -> Self {
61        Self::new("$azure", "device")
62    }
63
64    /// Creates a validator configured for Google Cloud `IoT`
65    #[must_use]
66    pub fn google_cloud_iot() -> Self {
67        Self::new("$gcp", "device")
68    }
69
70    /// Sets the device identifier for device-specific topic validation
71    ///
72    /// When set, allows topics like `{prefix}/{namespace}/{device_id}/*` while
73    /// rejecting `{prefix}/{namespace}/{other_device}/*`
74    #[must_use]
75    pub fn with_device_id(mut self, device_id: impl Into<String>) -> Self {
76        self.device_id = Some(device_id.into());
77        self
78    }
79
80    /// Enables system topics (`$SYS/*`)
81    #[must_use]
82    pub fn with_system_topics(mut self, allow: bool) -> Self {
83        self.allow_system_topics = allow;
84        self
85    }
86
87    /// Adds an additional reserved prefix
88    #[must_use]
89    pub fn with_reserved_prefix(mut self, prefix: impl Into<String>) -> Self {
90        self.additional_reserved_prefixes.push(prefix.into());
91        self
92    }
93
94    /// Checks if a topic is a service topic (e.g., `$aws/*`, `$azure/*`)
95    fn is_service_topic(&self, topic: &str) -> bool {
96        topic.starts_with(&format!("{}/", self.service_prefix))
97    }
98
99    /// Checks if a topic is a system topic (`$SYS/*`)  
100    fn is_system_topic(topic: &str) -> bool {
101        topic.starts_with("$SYS/")
102    }
103
104    /// Validates namespace-based topic restrictions
105    fn validate_namespace_restrictions(&self, topic: &str) -> Result<()> {
106        // Check system topics
107        if Self::is_system_topic(topic) && !self.allow_system_topics {
108            return Err(MqttError::InvalidTopicName(
109                "System topics ($SYS/*) are not allowed".to_string(),
110            ));
111        }
112
113        // Check service topics
114        if self.is_service_topic(topic) {
115            // For AWS IoT, most service topics are read-only and shouldn't be published to
116            if self.service_prefix == "$aws" {
117                // AWS IoT reserved topics that clients should not publish to
118                let aws_reserved_prefixes = ["$aws/certificates/", "$aws/provisioning-templates/"];
119
120                // Check if it's a reserved AWS topic
121                for reserved in &aws_reserved_prefixes {
122                    if topic.starts_with(reserved) {
123                        return Err(MqttError::InvalidTopicName(format!(
124                            "Cannot publish to reserved AWS IoT topic: {topic}"
125                        )));
126                    }
127                }
128
129                // Check device-specific topics for AWS
130                if topic.starts_with("$aws/things/") {
131                    // AWS IoT topics like $aws/things/{thing}/shadow/get/accepted are read-only
132                    // Only allow certain operations for publishing
133                    if let Some(ref device_id) = self.device_id {
134                        // Allow shadow update/delete and job operations for the configured device
135                        let allowed_patterns = [
136                            format!("$aws/things/{device_id}/shadow/update"),
137                            format!("$aws/things/{device_id}/shadow/delete"),
138                            format!("$aws/things/{device_id}/jobs/"),
139                        ];
140                        if !allowed_patterns
141                            .iter()
142                            .any(|pattern| topic.starts_with(pattern))
143                        {
144                            return Err(MqttError::InvalidTopicName(format!(
145                                "Cannot publish to reserved AWS IoT topic: {topic}"
146                            )));
147                        }
148                    } else {
149                        return Err(MqttError::InvalidTopicName(
150                            "Device-specific topics require device ID to be configured".to_string(),
151                        ));
152                    }
153                }
154            } else {
155                // Original logic for non-AWS providers
156                // Build the device namespace prefix (e.g., "$aws/thing/", "$azure/device/")
157                let device_namespace_prefix =
158                    format!("{}/{}/", self.service_prefix, self.device_namespace);
159
160                // If we have a device ID, check device-specific topics
161                if let Some(ref device_id) = self.device_id {
162                    let device_prefix = format!("{device_namespace_prefix}{device_id}/");
163
164                    // Allow device-specific topics
165                    if topic.starts_with(&device_prefix) {
166                        return Ok(());
167                    }
168
169                    // Allow general service topics that don't start with device namespace
170                    if !topic.starts_with(&device_namespace_prefix) {
171                        // These are general service topics like $aws/events, $azure/operations, etc.
172                        // Allow them for now, but this could be further restricted based on requirements
173                        return Ok(());
174                    }
175
176                    // Reject topics for other devices
177                    if topic.starts_with(&device_namespace_prefix) {
178                        return Err(MqttError::InvalidTopicName(format!(
179                            "Topic '{topic}' is for a different device. Only topics under '{device_prefix}' are allowed"
180                        )));
181                    }
182                } else {
183                    // No device ID set - reject all device-specific topics
184                    if topic.starts_with(&device_namespace_prefix) {
185                        return Err(MqttError::InvalidTopicName(format!(
186                            "Device-specific topics ({device_namespace_prefix}*) require device ID to be configured"
187                        )));
188                    }
189                    // Allow other service topics
190                }
191            }
192        }
193
194        // Check additional reserved prefixes
195        for prefix in &self.additional_reserved_prefixes {
196            if topic.starts_with(prefix) {
197                return Err(MqttError::InvalidTopicName(format!(
198                    "Topic '{topic}' uses reserved prefix '{prefix}'"
199                )));
200            }
201        }
202
203        Ok(())
204    }
205}
206
207impl TopicValidator for NamespaceValidator {
208    fn validate_topic_name(&self, topic: &str) -> Result<()> {
209        // First apply standard MQTT validation
210        validate_topic_name(topic)?;
211
212        // AWS IoT has a 256 character limit
213        if self.service_prefix == "$aws" && topic.len() > 256 {
214            return Err(MqttError::InvalidTopicName(
215                "AWS IoT topics must not exceed 256 characters".to_string(),
216            ));
217        }
218
219        // Then apply namespace-specific restrictions
220        self.validate_namespace_restrictions(topic)
221    }
222
223    fn validate_topic_filter(&self, filter: &str) -> Result<()> {
224        // First apply standard MQTT validation
225        validate_topic_filter(filter)?;
226
227        // AWS IoT has a 256 character limit
228        if self.service_prefix == "$aws" && filter.len() > 256 {
229            return Err(MqttError::InvalidTopicFilter(
230                "AWS IoT topic filters must not exceed 256 characters".to_string(),
231            ));
232        }
233
234        // For filters, we're more lenient with wildcards
235        // Only check if it's a literal reserved prefix (no wildcards)
236        if !filter.contains('+') && !filter.contains('#') {
237            self.validate_namespace_restrictions(filter)?;
238        }
239
240        Ok(())
241    }
242
243    fn is_reserved_topic(&self, topic: &str) -> bool {
244        // Check service topics
245        if self.is_service_topic(topic) {
246            let device_namespace_prefix =
247                format!("{}/{}/", self.service_prefix, self.device_namespace);
248
249            // If we have a device ID, only topics outside our device are reserved
250            if let Some(ref device_id) = self.device_id {
251                let device_prefix = format!("{device_namespace_prefix}{device_id}/");
252                return !topic.starts_with(&device_prefix)
253                    && topic.starts_with(&device_namespace_prefix);
254            }
255            // If no device ID, all device namespace topics are reserved
256            return topic.starts_with(&device_namespace_prefix);
257        }
258
259        // Check system topics
260        if Self::is_system_topic(topic) && !self.allow_system_topics {
261            return true;
262        }
263
264        // Check additional reserved prefixes
265        self.additional_reserved_prefixes
266            .iter()
267            .any(|prefix| topic.starts_with(prefix))
268    }
269
270    fn description(&self) -> &'static str {
271        "Namespace-based topic validator with hierarchical isolation"
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_namespace_validator_basic() {
281        let validator = NamespaceValidator::new("$aws", "thing");
282
283        // Regular topics should work
284        assert!(validator.validate_topic_name("sensor/temperature").is_ok());
285        assert!(validator.validate_topic_filter("sensor/+").is_ok());
286
287        // System topics should be rejected by default
288        assert!(validator
289            .validate_topic_name("$SYS/broker/version")
290            .is_err());
291        assert!(!validator.is_reserved_topic("regular/topic"));
292        assert!(validator.is_reserved_topic("$SYS/broker/version"));
293    }
294
295    #[test]
296    fn test_namespace_validator_with_device() {
297        let validator = NamespaceValidator::new("$aws", "things").with_device_id("my-device");
298
299        // Device-specific topics should work
300        assert!(validator
301            .validate_topic_name("$aws/things/my-device/shadow/update")
302            .is_ok());
303
304        // Other device topics should be rejected
305        assert!(validator
306            .validate_topic_name("$aws/things/other-device/shadow/update")
307            .is_err());
308
309        // General AWS topics should work
310        assert!(validator
311            .validate_topic_name("$aws/events/presence/connected/my-device")
312            .is_ok());
313    }
314
315    #[test]
316    fn test_namespace_validator_system_topics() {
317        let validator = NamespaceValidator::new("$aws", "thing").with_system_topics(true);
318
319        // System topics should now work
320        assert!(validator.validate_topic_name("$SYS/broker/version").is_ok());
321        assert!(!validator.is_reserved_topic("$SYS/broker/version"));
322    }
323
324    #[test]
325    fn test_namespace_validator_additional_prefixes() {
326        let validator = NamespaceValidator::new("$aws", "thing")
327            .with_reserved_prefix("company/")
328            .with_reserved_prefix("internal/");
329
330        // Additional reserved prefixes should be rejected
331        assert!(validator.validate_topic_name("company/secret").is_err());
332        assert!(validator.validate_topic_name("internal/admin").is_err());
333        assert!(validator.is_reserved_topic("company/secret"));
334
335        // Regular topics should work
336        assert!(validator.validate_topic_name("public/sensor").is_ok());
337    }
338
339    #[test]
340    fn test_different_cloud_providers() {
341        // Test AWS IoT
342        let aws = NamespaceValidator::aws_iot().with_device_id("sensor-123");
343        assert!(aws
344            .validate_topic_name("$aws/things/sensor-123/shadow/update")
345            .is_ok());
346        assert!(aws
347            .validate_topic_name("$aws/things/sensor-456/shadow/update")
348            .is_err());
349
350        // Test Azure IoT
351        let azure = NamespaceValidator::azure_iot().with_device_id("device-abc");
352        assert!(azure
353            .validate_topic_name("$azure/device/device-abc/telemetry")
354            .is_ok());
355        assert!(azure
356            .validate_topic_name("$azure/device/device-xyz/telemetry")
357            .is_err());
358
359        // Test custom enterprise
360        let enterprise = NamespaceValidator::new("$company", "asset").with_device_id("machine-001");
361        assert!(enterprise
362            .validate_topic_name("$company/asset/machine-001/status")
363            .is_ok());
364        assert!(enterprise
365            .validate_topic_name("$company/asset/machine-002/status")
366            .is_err());
367    }
368}