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}