Skip to main content

aimdb_core/session/
topic_match.rs

1//! Topic pattern matching over **dot-separated** record keys (pure `&str` ops,
2//! no_std-safe).
3//!
4//! Shared by every transport that supports wildcard subscriptions: the AimX
5//! wildcard subscribe ([`crate::session::aimx`]) matches a pattern against the
6//! registry once at subscribe time, and the WebSocket connector's fan-out bus
7//! matches per broadcast.
8//!
9//! The one segment separator is `.` — AimDB record keys are dot-delimited
10//! (`temp.vienna`, `app.config`), so wildcards split on `.`. The grammar
11//! (dot segments, `*` single-level, `#` multi-level) is RabbitMQ topic-exchange
12//! semantics: `#` absorbs **zero or more** segments and the pattern continues
13//! after it, so a `#` in the middle still has to line its suffix up. `/` is an
14//! ordinary character here — it belongs to external broker addresses
15//! (`mqtt://sensors/temp/x`), not to AimDB's subscription grammar.
16
17use core::str::Split;
18
19/// Returns `true` if `topic` matches `pattern`.
20///
21/// Wildcard conventions over **dot-separated** segments:
22///
23/// | Pattern  | Semantics                               |
24/// |----------|-----------------------------------------|
25/// | `#`      | Multi-level wildcard (all topics)       |
26/// | `a.#`    | `a` and everything under `a.`           |
27/// | `a.#.c`  | `a.c` and any `c` at any depth under it |
28/// | `a.*.c`  | Single-level wildcard in segment        |
29/// | `a.b.c`  | Exact match                             |
30pub fn topic_matches(pattern: &str, topic: &str) -> bool {
31    // Fast path: exact match
32    if pattern == topic {
33        return true;
34    }
35
36    // Multi-level wildcard: `#` matches everything
37    if pattern == "#" {
38        return true;
39    }
40
41    // `prefix.#` matches everything under prefix — only when prefix is literal
42    // (no wildcards in the prefix). Same answer as the general walk below, just
43    // without splitting: this is the shape the WS fan-out matches per broadcast.
44    if let Some(prefix) = pattern.strip_suffix(".#") {
45        if !prefix.contains('*') && !prefix.contains('#') {
46            return topic.starts_with(prefix)
47                && (topic.len() == prefix.len()
48                    || topic.as_bytes().get(prefix.len()) == Some(&b'.'));
49        }
50    }
51
52    // A topic segment is always a literal, so a pattern segment covers it when
53    // it is that same literal or the single-level wildcard.
54    match_segments(pattern, topic, |p, t| p == "*" || p == t)
55}
56
57/// Segment walk shared by [`topic_matches`] and [`pattern_contains`]: the two
58/// differ only in when one `pattern` segment covers one `subject` segment, which
59/// is what `covers` decides. `#` is handled here because it is the one construct
60/// that spans segments.
61///
62/// `#` absorbs **zero or more** subject segments and the pattern *continues*
63/// after it — it is not a "match everything from here" escape hatch. That
64/// distinction is load-bearing: returning `true` at the `#` would make the
65/// suffix of `a.#.secret` dead text and let it cover `a.public`.
66///
67/// Only the most recent `#` is retained as a backtrack point. That is the
68/// standard glob trick, and it is what bounds this at `O(|pattern| · |subject|)`
69/// rather than exponential — worth keeping deliberate, since `subject` is the
70/// client-supplied pattern on the [`pattern_contains`] ACL path.
71fn match_segments<F>(pattern: &str, subject: &str, covers: F) -> bool
72where
73    F: Fn(&str, &str) -> bool,
74{
75    let mut pat = pattern.split('.');
76    let mut sub = subject.split('.');
77    // The pattern tail following the most recent `#`, paired with the subject
78    // position that `#` currently stops absorbing at. `None` until a `#` is seen.
79    let mut resume: Option<(Split<'_, char>, Split<'_, char>)> = None;
80
81    loop {
82        let mut pat_rest = pat.clone();
83        let mut sub_rest = sub.clone();
84        match (pat_rest.next(), sub_rest.next()) {
85            // `#` starts out absorbing nothing; `resume` is how it grows.
86            (Some("#"), _) => {
87                pat = pat_rest;
88                resume = Some((pat.clone(), sub.clone()));
89            }
90            (Some(p), Some(s)) if covers(p, s) => {
91                pat = pat_rest;
92                sub = sub_rest;
93            }
94            // Both exhausted together — every segment was covered.
95            (None, None) => return true,
96            // Mismatch, or one side ran out while the other still has segments.
97            // The only way forward is to let the most recent `#` swallow one
98            // more subject segment and retry its suffix from there.
99            _ => {
100                let Some((pat_after_hash, sub_at_hash)) = resume.take() else {
101                    return false; // no `#` to fall back on
102                };
103                let mut grown = sub_at_hash;
104                if grown.next().is_none() {
105                    return false; // `#` has nothing left to absorb
106                }
107                pat = pat_after_hash.clone();
108                sub = grown.clone();
109                resume = Some((pat_after_hash, grown));
110            }
111        }
112    }
113}
114
115/// Returns `true` if `pattern` contains a wildcard segment — i.e. subscribing
116/// to it means "match against the registry" rather than "resolve one key".
117pub fn is_wildcard(pattern: &str) -> bool {
118    pattern.contains('#') || pattern.contains('*')
119}
120
121/// Returns `true` if every topic matched by `requested` is also matched by
122/// `grant` — i.e. `grant`'s match set ⊇ `requested`'s match set (`grant`
123/// *contains* `requested`).
124///
125/// This is **pattern containment**, not topic matching, and the two diverge
126/// once `requested` is itself a wildcard. An access check must ask containment:
127/// "does my grant cover the whole pattern this client asked to subscribe to?"
128/// [`topic_matches`] answers a different question ("does this pattern match
129/// this string?") and would treat the requested pattern as an opaque topic —
130/// so a one-level grant `a.*` "matches" the all-levels request `a.#` (the `*`
131/// swallows the `#`) and silently widens the grant. Containment denies that:
132/// `a.#` reaches deeper than `a.*` covers.
133///
134/// When `requested` is a concrete topic (no wildcard) this collapses to exactly
135/// [`topic_matches`]`(grant, requested)`, so it is a safe drop-in for an ACL
136/// that must accept both concrete and wildcard subscription requests.
137///
138/// Grammar is the same dot-separated `*` (single-level) / `#` (multi-level,
139/// zero-or-more) set as [`topic_matches`]. A grant's `#` absorbs zero or more
140/// *requested* segments and the grant continues after it, so an interior `#`
141/// constrains rather than blanket-allows: `a.#.secret` contains `a.b.secret`
142/// but not `a.public`.
143///
144/// # Conservative by construction
145///
146/// This decides containment with a single left-to-right walk, which is sound
147/// (it never reports containment that does not hold) but not complete. Deciding
148/// the remaining cases means reasoning about every expansion of the *request's*
149/// `#`, and that ∀-branch is exactly the intricate, blowup-prone code an ACL on
150/// client-supplied input should not carry.
151///
152/// The gap is confined to a grant whose `*` meets a request's `#`, where a later
153/// segment would have ruled out the short expansions — e.g. `a.*.#` does contain
154/// `a.#.a` (every topic the request admits has ≥2 segments), but this returns
155/// `false`. Such a pair denies access that could have been allowed; it never
156/// allows access that should have been denied. Grants without a `*`/`#`
157/// adjacency — every ordinary shape, including `a.#`, `a.*`, `a.#.b`, `#` — are
158/// decided exactly.
159pub fn pattern_contains(grant: &str, requested: &str) -> bool {
160    match_segments(grant, requested, |g, r| {
161        if g == "*" {
162            // A grant `*` covers exactly one segment. A request `#` could expand
163            // to zero or many, so `*` cannot contain it; a request `*` or literal
164            // is exactly one segment and is covered.
165            r != "#"
166        } else {
167            // A grant literal covers only itself: the request segment must be
168            // that same literal (a request `*`/`#` here would reach topics the
169            // literal doesn't). A grant `#` never arrives here — the walk owns it.
170            g == r
171        }
172    })
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn exact_match() {
181        assert!(topic_matches("a.b.c", "a.b.c"));
182        assert!(!topic_matches("a.b.c", "a.b.d"));
183    }
184
185    #[test]
186    fn hash_wildcard() {
187        assert!(topic_matches("#", "anything.goes.here"));
188        assert!(topic_matches("#", "a"));
189    }
190
191    #[test]
192    fn prefix_hash_wildcard() {
193        assert!(topic_matches("sensors.#", "sensors.temperature.vienna"));
194        assert!(topic_matches("sensors.#", "sensors.humidity.berlin"));
195        assert!(!topic_matches("sensors.#", "commands.setpoint"));
196        // Edge: prefix itself
197        assert!(topic_matches("sensors.#", "sensors"));
198        // A literal prefix is a whole segment — `sensors.#` must not swallow a
199        // key that merely *starts with* the string "sensors".
200        assert!(!topic_matches("sensors.#", "sensors_extra.temp"));
201    }
202
203    #[test]
204    fn star_wildcard() {
205        assert!(topic_matches(
206            "sensors.temperature.*",
207            "sensors.temperature.vienna"
208        ));
209        assert!(topic_matches(
210            "sensors.temperature.*",
211            "sensors.temperature.berlin"
212        ));
213        assert!(!topic_matches(
214            "sensors.temperature.*",
215            "sensors.humidity.vienna"
216        ));
217        assert!(!topic_matches(
218            "sensors.temperature.*",
219            "sensors.temperature.a.b"
220        ));
221    }
222
223    #[test]
224    fn star_matches_dotted_key_below_top_level() {
225        // A single-segment-below wildcard must match a dot-separated key:
226        // `temp.*` matches `temp.vienna`, not the old bug where `/`-splitting
227        // compared the literals `"temp.*"` and `"temp.vienna"`.
228        assert!(topic_matches("temp.*", "temp.vienna"));
229        assert!(topic_matches("temp.*", "temp.berlin"));
230        assert!(!topic_matches("temp.*", "temp"));
231        assert!(!topic_matches("temp.*", "temp.vienna.indoor"));
232        assert!(!topic_matches("temp.*", "humidity.vienna"));
233    }
234
235    #[test]
236    fn mixed_wildcards() {
237        assert!(topic_matches("a.*.c.#", "a.b.c.d.e.f"));
238        assert!(!topic_matches("a.*.c.#", "a.b.x.d"));
239    }
240
241    #[test]
242    fn non_terminal_hash_still_matches_its_suffix() {
243        // Regression: `#` used to return `true` on sight, which made every
244        // segment after it dead text — `a.#.secret` matched *anything* under
245        // `a`. It absorbs zero or more segments and the suffix must still line up.
246        assert!(!topic_matches("a.#.secret", "a.public"));
247        assert!(!topic_matches("a.#.secret", "a.b.c.public"));
248        assert!(!topic_matches("a.#.secret", "b.secret")); // prefix still binds
249        assert!(!topic_matches("a.#.secret", "a.secret.tail"));
250        // Zero-or-more: the `#` may absorb nothing, one segment, or many.
251        assert!(topic_matches("a.#.secret", "a.secret"));
252        assert!(topic_matches("a.#.secret", "a.b.secret"));
253        assert!(topic_matches("a.#.secret", "a.b.c.d.secret"));
254    }
255
256    #[test]
257    fn hash_backtracks_to_find_a_later_suffix() {
258        // The suffix appears twice: the `#` has to keep growing past the first
259        // occurrence to line the pattern up on the last one.
260        assert!(topic_matches("a.#.c", "a.c.b.c"));
261        assert!(!topic_matches("a.#.c", "a.c.b.d"));
262        // Several `#` in one pattern each need their own extension.
263        assert!(topic_matches("a.#.b.#.c", "a.x.y.b.z.c"));
264        assert!(!topic_matches("a.#.b.#.c", "a.x.y.b.z.d"));
265        // A trailing `#` after an interior one still absorbs the remainder.
266        assert!(topic_matches("a.#.b.#", "a.x.b.y.z"));
267        assert!(topic_matches("a.#.b.#", "a.b")); // both absorb zero
268        assert!(!topic_matches("a.#.b.#", "a.x.y"));
269    }
270
271    #[test]
272    fn hash_does_not_match_a_shorter_topic_than_its_suffix() {
273        // `#` absorbing zero segments must not let the pattern outrun the topic.
274        assert!(!topic_matches("a.#.b.c", "a.b"));
275        assert!(!topic_matches("#.a", "b"));
276        assert!(topic_matches("#.a", "a")); // leading `#` absorbs zero
277        assert!(topic_matches("#.a", "x.y.a"));
278    }
279
280    #[test]
281    fn slash_is_not_a_separator() {
282        // `/` is an ordinary character — a slash key is one literal segment, so a
283        // dot wildcard doesn't reach into it and a slash "wildcard" isn't one.
284        assert!(!topic_matches("sensors.#", "sensors/temp/vienna"));
285        assert!(topic_matches("sensors/temp", "sensors/temp")); // exact still works
286        assert!(!is_wildcard("sensors/temp")); // no `#`/`*` → literal
287    }
288
289    #[test]
290    fn wildcard_detection() {
291        assert!(is_wildcard("#"));
292        assert!(is_wildcard("sensors.#"));
293        assert!(is_wildcard("a.*.c"));
294        // Literal dotted keys are wildcards only when `#`/`*` appears.
295        assert!(!is_wildcard("temp.vienna"));
296        assert!(is_wildcard("temp.*"));
297    }
298
299    #[test]
300    fn containment_denies_wildcard_escalation() {
301        // The bug this function exists to prevent: a one-level grant must not
302        // cover an all-levels request just because `topic_matches` lets the
303        // grant's `*` swallow the request's `#`.
304        assert!(!pattern_contains("sensors.*", "sensors.#"));
305        assert!(topic_matches("sensors.*", "sensors.#")); // the trap it avoids
306                                                          // `*` also can't cover a deeper concrete request.
307        assert!(!pattern_contains("sensors.*", "sensors.temp.vienna"));
308    }
309
310    #[test]
311    fn containment_allows_when_grant_is_broader_or_equal() {
312        // `#` covers everything.
313        assert!(pattern_contains("#", "sensors.#"));
314        assert!(pattern_contains("#", "anything.deep.here"));
315        // A grant `#` tail covers deeper requests and the prefix itself.
316        assert!(pattern_contains("sensors.#", "sensors.temp.#"));
317        assert!(pattern_contains("sensors.#", "sensors.*"));
318        assert!(pattern_contains("sensors.#", "sensors.temp"));
319        assert!(pattern_contains("sensors.#", "sensors")); // `a.#` contains `a`
320                                                           // Equal patterns contain each other.
321        assert!(pattern_contains("sensors.#", "sensors.#"));
322        assert!(pattern_contains("a.*.c", "a.*.c"));
323        // One-level grant covers one-level requests (literal or `*`).
324        assert!(pattern_contains("sensors.*", "sensors.temp"));
325        assert!(pattern_contains("sensors.*", "sensors.*"));
326    }
327
328    #[test]
329    fn containment_denies_out_of_scope_or_shallower() {
330        // Different subtree.
331        assert!(!pattern_contains("sensors.#", "commands.#"));
332        assert!(!pattern_contains("sensors.temp", "sensors.humidity"));
333        // A deeper grant does not cover a shallower request.
334        assert!(!pattern_contains("a.b.c", "a.b"));
335        // A literal grant is not widened by a `*`/`#` request.
336        assert!(!pattern_contains("sensors.temp", "sensors.*"));
337        assert!(!pattern_contains("sensors.temp", "sensors.#"));
338    }
339
340    #[test]
341    fn containment_honours_a_non_terminal_hash_in_the_grant() {
342        // Regression: the grant's `#` used to return `true` on sight, so a grant
343        // written to reach only `secret` leaves widened the ACL to the whole
344        // subtree. The suffix after the `#` must still constrain the request.
345        assert!(!pattern_contains("a.#.secret", "a.public"));
346        assert!(!pattern_contains("a.#.secret", "a.b.public"));
347        assert!(!pattern_contains("a.#.secret", "a.#")); // no escalation to the subtree
348        assert!(!pattern_contains("a.#.secret", "a.*"));
349        // What the grant genuinely covers stays covered.
350        assert!(pattern_contains("a.#.secret", "a.secret"));
351        assert!(pattern_contains("a.#.secret", "a.b.secret"));
352        assert!(pattern_contains("a.#.secret", "a.#.secret"));
353        assert!(pattern_contains("a.#.secret", "a.b.#.secret"));
354    }
355
356    #[test]
357    fn containment_denies_requests_reaching_past_an_interior_hash() {
358        // A request `#` where the grant demands a concrete tail is unbounded and
359        // must be denied — it would reach topics the grant's suffix excludes.
360        assert!(!pattern_contains("a.#.b", "a.#"));
361        assert!(!pattern_contains("a.#.b", "#"));
362        assert!(!pattern_contains("a.#.b.c", "a.b"));
363        // A `*` cannot stand in for a request `#`, before or after a grant `#`.
364        assert!(!pattern_contains("a.#.*", "a.#"));
365        assert!(pattern_contains("a.#.*", "a.b.c"));
366        assert!(!pattern_contains("a.#.*", "a"));
367    }
368
369    #[test]
370    fn containment_is_conservative_where_a_grant_star_meets_a_request_hash() {
371        // Pins the documented soundness/completeness trade-off so a future change
372        // has to be deliberate. `a.*.#` genuinely contains `a.#.a` — every topic
373        // the request admits has ≥2 segments, which is exactly what `*.#`
374        // requires — but deciding that needs reasoning over every expansion of
375        // the request's `#`, which this walk does not do. It denies instead.
376        assert!(!pattern_contains("a.*.#", "a.#.a"));
377        assert!(!pattern_contains("a.#.*", "a.b.#"));
378        // The direction that matters: the denial is fail-closed, so no topic the
379        // request admits is reachable in a way the grant would not have allowed.
380        // (Sanity: the grant really is the broader pattern here.)
381        for topic in ["a.a", "a.b.a", "a.b.c.a"] {
382            assert!(topic_matches("a.#.a", topic));
383            assert!(topic_matches("a.*.#", topic));
384        }
385        // Ordinary grants — no `*`/`#` adjacency — stay exact, not conservative.
386        assert!(pattern_contains("a.#", "a.b.#"));
387        assert!(pattern_contains("a.#.b", "a.x.b"));
388        assert!(pattern_contains("a.*", "a.b"));
389    }
390
391    #[test]
392    fn containment_matches_topic_matches_on_concrete_requests() {
393        // For a concrete (wildcard-free) request, containment must be exactly
394        // `topic_matches` — the safe-drop-in property the ACL relies on.
395        let grants = [
396            "#",
397            "sensors.#",
398            "sensors.*",
399            "sensors.temp",
400            "*.temp",
401            // Interior `#` — the shape that used to short-circuit both walks.
402            "sensors.#.temp",
403            "#.temp",
404            "sensors.#.*",
405            "a.#.b.#.c",
406        ];
407        let topics = [
408            "sensors.temp",
409            "sensors.temp.vienna",
410            "sensors",
411            "commands.on",
412            "a.temp",
413            "sensors.rack.temp",
414            "sensors.rack.shelf.temp",
415            "a.x.b.y.c",
416            "a.b.c",
417        ];
418        for g in grants {
419            for t in topics {
420                assert_eq!(
421                    pattern_contains(g, t),
422                    topic_matches(g, t),
423                    "grant={g:?} topic={t:?}"
424                );
425            }
426        }
427    }
428}