Skip to main content

rust_mqtt/types/
topic.rs

1use const_fn::const_fn;
2use heapless::Vec;
3
4use crate::{
5    client::options::{RetainHandling, SubscriptionOptions},
6    eio::Write,
7    fmt::const_debug_assert,
8    io::{
9        err::WriteError,
10        write::{Writable, wlen},
11    },
12    types::MqttString,
13};
14
15/// A topic name string for that messages can be published on according to <https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901241>.
16/// Cannot contain wildcard characters.
17///
18/// Examples:
19/// - "sport/tennis/player1"
20/// - "sport/tennis/player1/ranking"
21/// - "sport/tennis/player1/score/wimbledon"
22#[derive(Debug, Clone, PartialEq, Eq)]
23#[cfg_attr(feature = "defmt", derive(defmt::Format))]
24pub struct TopicName<'t>(MqttString<'t>);
25
26impl<'t> TopicName<'t> {
27    const fn is_valid(s: &MqttString) -> bool {
28        let s = s.as_str().as_bytes();
29
30        // [MQTT-4.7.3-1]
31        // Topic names must be at least one character long.
32        if s.is_empty() {
33            return false;
34        }
35
36        let mut i = 0;
37
38        while i < s.len() {
39            let b = s[i];
40
41            // [MQTT-4.7.3-2]
42            // Topic names must not include the null character.
43            // No null characters are an invariant of `MqttString`
44
45            // [MQTT-4.7.0-1]
46            // Wildcard characters must not be used within a topic name.
47            if b == b'+' || b == b'#' {
48                return false;
49            }
50
51            i += 1;
52        }
53
54        true
55    }
56
57    /// Creates a new topic name while checking for correct syntax of the topic name string.
58    #[const_fn(cfg(not(feature = "alloc")))]
59    #[must_use]
60    pub fn new(string: MqttString<'t>) -> Option<Self> {
61        if Self::is_valid(&string) {
62            Some(Self(string))
63        } else {
64            None
65        }
66    }
67
68    /// Creates a new topic name without checking for correct syntax of the topic name string.
69    ///
70    /// # Invariants
71    /// The syntax of the topic name is valid. For a fallible version, use [`TopicName::new`]
72    ///
73    /// # Panics
74    /// In debug builds, this function will panic if the syntax of `string` is incorrect.
75    #[must_use]
76    pub const fn new_unchecked(string: MqttString<'t>) -> Self {
77        const_debug_assert!(
78            Self::is_valid(&string),
79            "the provided string is not valid TopicName syntax"
80        );
81
82        Self(string)
83    }
84
85    /// Delegates to [`crate::Bytes::as_borrowed`].
86    #[inline]
87    #[must_use]
88    pub const fn as_borrowed(&'t self) -> Self {
89        Self(self.0.as_borrowed())
90    }
91}
92
93impl<'t> AsRef<MqttString<'t>> for TopicName<'t> {
94    fn as_ref(&self) -> &MqttString<'t> {
95        &self.0
96    }
97}
98impl<'t> From<TopicName<'t>> for MqttString<'t> {
99    fn from(value: TopicName<'t>) -> Self {
100        value.0
101    }
102}
103
104/// A topic filter string for subscribing to certain topics according to <https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901241>.
105/// Can contain wildcard characters.
106///
107/// Examples:
108/// - "sport/tennis/#"
109/// - "sport/+/player1"
110#[derive(Debug, Clone, PartialEq, Eq)]
111#[cfg_attr(feature = "defmt", derive(defmt::Format))]
112pub struct TopicFilter<'t>(MqttString<'t>);
113
114impl<'t> TopicFilter<'t> {
115    const fn is_valid(s: &MqttString) -> bool {
116        let s = s.as_str().as_bytes();
117
118        // [MQTT-4.7.3-1]
119        // Topic filters must be at least one character long.
120        if s.is_empty() {
121            return false;
122        }
123
124        let mut i = 0;
125        let mut level_len = 0;
126        let mut level_contains_wildcard = false;
127
128        while i < s.len() {
129            let b = s[i];
130
131            // [MQTT-4.7.3-2]
132            // Topic filters must not include the null character.
133            // No null characters are an invariant of `MqttString`
134
135            if b == b'#' {
136                // [MQTT-4.7.1-1]
137                // The multi-level wildcard character must be specified on its own.
138                // The multi-level wildcard character must be the last character specified in the topic filter.
139                if i == s.len() - 1 {
140                    level_contains_wildcard = true;
141                } else {
142                    return false;
143                }
144            }
145
146            if b == b'+' {
147                level_contains_wildcard = true;
148            }
149
150            if b == b'/' {
151                level_len = 0;
152                level_contains_wildcard = false;
153            } else {
154                level_len += 1;
155
156                // [MQTT-4.7.1-2]
157                // The single-level wildcard must occupy an entire level of the filter.
158                // [MQTT-4.7.1-1]
159                // The multi-level wildcard character must be specified on its own.
160                if level_len > 1 && level_contains_wildcard {
161                    return false;
162                }
163            }
164
165            i += 1;
166        }
167
168        true
169    }
170
171    /// Creates a new topic filter while checking for correct syntax of the topic filter string
172    #[const_fn(cfg(not(feature = "alloc")))]
173    #[must_use]
174    pub fn new(string: MqttString<'t>) -> Option<Self> {
175        if Self::is_valid(&string) {
176            Some(Self(string))
177        } else {
178            None
179        }
180    }
181
182    /// Creates a new topic filter without checking for correct syntax of the topic filter string.
183    ///
184    /// # Invariants
185    /// The syntax of the topic filter is valid. For a fallible version, use [`TopicFilter::new`].
186    ///
187    /// # Panics
188    /// In debug builds, this function will panic if the syntax of `string` is incorrect.
189    #[must_use]
190    pub const fn new_unchecked(string: MqttString<'t>) -> Self {
191        const_debug_assert!(
192            Self::is_valid(&string),
193            "the provided string is not valid TopicFilter syntax"
194        );
195
196        Self(string)
197    }
198
199    /// Delegates to [`crate::Bytes::as_borrowed`].
200    #[inline]
201    #[must_use]
202    pub const fn as_borrowed(&'t self) -> Self {
203        Self(self.0.as_borrowed())
204    }
205}
206
207impl<'t> AsRef<MqttString<'t>> for TopicFilter<'t> {
208    fn as_ref(&self) -> &MqttString<'t> {
209        &self.0
210    }
211}
212impl<'t> From<TopicFilter<'t>> for MqttString<'t> {
213    fn from(value: TopicFilter<'t>) -> Self {
214        value.0
215    }
216}
217impl<'t> From<TopicName<'t>> for TopicFilter<'t> {
218    fn from(value: TopicName<'t>) -> Self {
219        Self(value.0)
220    }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
224#[cfg_attr(feature = "defmt", derive(defmt::Format))]
225pub struct SubscriptionFilter<'t> {
226    topic: TopicFilter<'t>,
227    subscription_options: u8,
228}
229
230impl<const MAX_TOPIC_FILTERS: usize> Writable for Vec<SubscriptionFilter<'_>, MAX_TOPIC_FILTERS> {
231    fn written_len(&self) -> usize {
232        self.iter()
233            .map(|t| &t.topic)
234            .map(|t| t.written_len() + wlen!(u8))
235            .sum()
236    }
237
238    async fn write<W: Write>(&self, write: &mut W) -> Result<(), WriteError<W::Error>> {
239        for t in self {
240            t.topic.write(write).await?;
241            t.subscription_options.write(write).await?;
242        }
243
244        Ok(())
245    }
246}
247
248impl<'t> SubscriptionFilter<'t> {
249    pub const fn new(topic: TopicFilter<'t>, options: &SubscriptionOptions) -> Self {
250        let retain_handling_bits = match options.retain_handling {
251            RetainHandling::AlwaysSend => 0x00,
252            RetainHandling::SendIfNotSubscribedBefore => 0x10,
253            RetainHandling::NeverSend => 0x20,
254        };
255
256        let retain_as_published_bit = match options.retain_as_published {
257            true => 0x08,
258            false => 0x00,
259        };
260
261        let no_local_bit = match options.no_local {
262            true => 0x04,
263            false => 0x00,
264        };
265
266        let qos_bits = options.qos.into_bits(0);
267
268        let subscribe_options_bits =
269            retain_handling_bits | retain_as_published_bit | no_local_bit | qos_bits;
270
271        Self {
272            topic,
273            subscription_options: subscribe_options_bits,
274        }
275    }
276}
277
278#[cfg(test)]
279mod unit {
280    use tokio_test::assert_ok;
281
282    use crate::types::{MqttString, TopicFilter, TopicName};
283
284    macro_rules! assert_valid {
285        ($t:ty, $l:literal) => {
286            let s = assert_ok!(MqttString::from_str($l));
287            assert!(<$t>::new(s).is_some())
288        };
289    }
290    macro_rules! assert_invalid {
291        ($t:ty, $l:literal) => {
292            match MqttString::from_str($l) {
293                Ok(s) => assert!(<$t>::new(s).is_none()),
294                Err(_) => {}
295            }
296        };
297    }
298
299    #[test]
300    fn topic_name_zero_characters() {
301        assert_invalid!(TopicName, "");
302    }
303
304    #[test]
305    fn topic_name_null_character() {
306        assert_invalid!(TopicName, "he\0/yo");
307    }
308
309    #[test]
310    fn topic_name_with_wildcard() {
311        assert_invalid!(TopicName, "+wrong");
312        assert_invalid!(TopicName, "wro#ng");
313        assert_invalid!(TopicName, "w/r/o/n/g+");
314        assert_invalid!(TopicName, "w/r/o/+/g");
315        assert_invalid!(TopicName, "wrong/#/path");
316        assert_invalid!(TopicName, "wrong/+/path");
317        assert_invalid!(TopicName, "wrong/path/#");
318        assert_invalid!(TopicName, "#");
319        assert_invalid!(TopicName, "+");
320    }
321
322    #[test]
323    fn topic_name_valid() {
324        assert_valid!(TopicName, "/");
325        assert_valid!(TopicName, "r");
326        assert_valid!(TopicName, "right");
327        assert_valid!(TopicName, "sport/tennis/player1");
328        assert_valid!(TopicName, "sport/tennis/player1/ranking");
329        assert_valid!(TopicName, "sport/tennis/player1/score/wimbledon");
330    }
331
332    #[test]
333    fn topic_filter_zero_characters() {
334        assert_invalid!(TopicFilter, "");
335    }
336
337    #[test]
338    fn topic_filter_null_character() {
339        assert_invalid!(TopicFilter, "he\0/yo");
340    }
341
342    #[test]
343    fn topic_filter_with_invalid_wildcard() {
344        assert_invalid!(TopicFilter, "++/");
345        assert_invalid!(TopicFilter, "/++");
346
347        assert_invalid!(TopicFilter, "a+/");
348        assert_invalid!(TopicFilter, "+a/");
349        assert_invalid!(TopicFilter, "/a+/");
350        assert_invalid!(TopicFilter, "/+a/");
351        assert_invalid!(TopicFilter, "/a+");
352
353        assert_invalid!(TopicFilter, "##");
354        assert_invalid!(TopicFilter, "a#");
355        assert_invalid!(TopicFilter, "#a");
356
357        assert_invalid!(TopicFilter, "a#/");
358        assert_invalid!(TopicFilter, "#a/");
359        assert_invalid!(TopicFilter, "/a#/");
360        assert_invalid!(TopicFilter, "/#a/");
361        assert_invalid!(TopicFilter, "/a#");
362        assert_invalid!(TopicFilter, "/#a");
363
364        assert_invalid!(TopicFilter, "+wrong");
365        assert_invalid!(TopicFilter, "wro#ng");
366        assert_invalid!(TopicFilter, "w/r/o/n/g+");
367        assert_invalid!(TopicFilter, "wrong/#/path");
368    }
369
370    #[test]
371    fn topic_filter_valid() {
372        assert_valid!(TopicFilter, "#");
373        assert_valid!(TopicFilter, "/#");
374        assert_valid!(TopicFilter, "a/#");
375
376        assert_valid!(TopicFilter, "+");
377        assert_valid!(TopicFilter, "/+");
378        assert_valid!(TopicFilter, "+/");
379        assert_valid!(TopicFilter, "a/+");
380        assert_valid!(TopicFilter, "+/a");
381
382        assert_valid!(TopicFilter, "/");
383        assert_valid!(TopicFilter, "//");
384        assert_valid!(TopicFilter, "r");
385
386        assert_valid!(TopicFilter, "r/i/g/+/t");
387        assert_valid!(TopicFilter, "correct/+/path");
388        assert_valid!(TopicFilter, "right/path/#");
389        assert_valid!(TopicFilter, "right");
390        assert_valid!(TopicFilter, "sport/tennis/player1");
391        assert_valid!(TopicFilter, "sport/tennis/player1/ranking");
392        assert_valid!(TopicFilter, "sport/tennis/player1/score/wimbledon");
393    }
394}