Skip to main content

astrid_events/route/
matcher.rs

1//! Topic-pattern matching + small shared helpers used by the routing
2//! demux. Kept separate so the route table itself stays focused on the
3//! state machine.
4
5use std::sync::Arc;
6
7use crate::event::AstridEvent;
8
9/// Compiled topic-pattern matcher. Mirrors the segment-aware matching
10/// semantics of `EventReceiver::matches` so the broadcast and routed
11/// paths agree on what each pattern means.
12#[derive(Debug, Clone)]
13pub struct TopicMatcher {
14    pattern: String,
15}
16
17impl TopicMatcher {
18    /// Maximum allowed topic depth (dot-separated segments). Matches
19    /// the bus-level cap.
20    pub const MAX_TOPIC_DEPTH: usize = 20;
21
22    /// Compile a topic pattern. The pattern follows the same grammar as
23    /// `subscribe_topic_as`: exact match, trailing `*` for namespace
24    /// match, mid-segment `*` for single-segment wildcard.
25    pub fn new(pattern: impl Into<String>) -> Self {
26        Self {
27            pattern: pattern.into(),
28        }
29    }
30
31    /// True iff the event matches this pattern. Non-IPC events never
32    /// match a routed pattern.
33    #[must_use]
34    pub fn matches(&self, event: &AstridEvent) -> bool {
35        let AstridEvent::Ipc { message, .. } = event else {
36            return false;
37        };
38        self.matches_topic(&message.topic)
39    }
40
41    /// True iff `topic` matches this pattern. Thin wrapper over the shared,
42    /// allocation-free [`topic_pattern_matches`].
43    #[must_use]
44    pub fn matches_topic(&self, topic: &str) -> bool {
45        topic_pattern_matches(&self.pattern, topic)
46    }
47
48    /// Pattern as configured.
49    #[must_use]
50    pub fn pattern(&self) -> &str {
51        &self.pattern
52    }
53}
54
55/// True iff `topic` matches `pattern` using trailing-`*`-is-subtree semantics:
56/// a trailing `*` matches one OR MORE remaining segments at any depth, a
57/// mid-segment `*` matches exactly one segment, otherwise segment counts must
58/// be equal. Allocation-free — iterates the segment splits directly.
59///
60/// The single source of truth shared by routed delivery ([`TopicMatcher`]),
61/// broadcast delivery (`EventReceiver::matches`), and the capsule
62/// publish/subscribe ACL authorization, so what a capsule is *allowed* to
63/// publish/subscribe can never diverge from what is actually delivered.
64/// `topic` is either a concrete published topic or a requested sub-pattern
65/// being authorized against an ACL entry.
66#[must_use]
67pub fn topic_pattern_matches(pattern: &str, topic: &str) -> bool {
68    if topic.split('.').count() > TopicMatcher::MAX_TOPIC_DEPTH {
69        return false;
70    }
71
72    if let Some(prefix_pat) = pattern.strip_suffix(".*") {
73        // Trailing `*`: the topic must be strictly deeper than the prefix and
74        // every prefix segment must match (the `*` covers 1+ remaining).
75        topic.split('.').count() > prefix_pat.split('.').count()
76            && prefix_pat
77                .split('.')
78                .zip(topic.split('.'))
79                .all(|(p, t)| p == "*" || p == t)
80    } else {
81        // Exact: equal segment count, each pair single-segment-wildcard match.
82        pattern.split('.').count() == topic.split('.').count()
83            && pattern
84                .split('.')
85                .zip(topic.split('.'))
86                .all(|(p, t)| p == "*" || p == t)
87    }
88}
89
90/// Approximate the byte cost of an event for budget bookkeeping. The
91/// `Arc<AstridEvent>` size in memory dwarfs the `IpcPayload` content for
92/// small messages; we charge the payload's JSON length so a flood of
93/// 1-byte payloads doesn't masquerade as a 0-byte stream. Non-IPC
94/// events fall back to a flat constant since they don't route through
95/// here in practice.
96#[must_use]
97pub fn ipc_size_of(event: &Arc<AstridEvent>) -> usize {
98    match &**event {
99        AstridEvent::Ipc { message, .. } => message
100            .payload
101            .to_guest_bytes()
102            .map_or(0, |v| v.len())
103            .saturating_add(message.topic.len()),
104        _ => 64,
105    }
106}
107
108/// Label for telemetry classification. Identical mapping to
109/// `astrid_capsule::principal_class::PrincipalClass::as_label` so
110/// `principal_class` labels collide across both crates.
111#[must_use]
112pub fn principal_class_label(principal: Option<&str>) -> &'static str {
113    match principal {
114        None => "system",
115        Some(p) if p.starts_with("agent.") || p.starts_with("agent:") => "agent",
116        Some(_) => "user",
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::event::EventMetadata;
124    use crate::ipc::{IpcMessage, IpcPayload};
125    use serde_json::json;
126    use uuid::Uuid;
127
128    fn ipc(topic: &str) -> Arc<AstridEvent> {
129        let msg = IpcMessage::new(topic, IpcPayload::RawJson(json!({})), Uuid::nil());
130        Arc::new(AstridEvent::Ipc {
131            metadata: EventMetadata::new("test"),
132            message: msg,
133        })
134    }
135
136    #[test]
137    fn topic_matcher_exact() {
138        let m = TopicMatcher::new("a.b.c");
139        assert!(m.matches(&ipc("a.b.c")));
140        assert!(!m.matches(&ipc("a.b.d")));
141        assert!(!m.matches(&ipc("a.b")));
142        assert!(!m.matches(&ipc("a.b.c.d")));
143    }
144
145    #[test]
146    fn topic_matcher_trailing_wildcard() {
147        let m = TopicMatcher::new("a.b.*");
148        assert!(m.matches(&ipc("a.b.c")));
149        assert!(m.matches(&ipc("a.b.c.d")));
150        assert!(!m.matches(&ipc("a.b")));
151        assert!(!m.matches(&ipc("a.c.b")));
152    }
153
154    #[test]
155    fn topic_matcher_middle_wildcard() {
156        let m = TopicMatcher::new("a.*.c");
157        assert!(m.matches(&ipc("a.b.c")));
158        assert!(m.matches(&ipc("a.zz.c")));
159        assert!(!m.matches(&ipc("a.b.d")));
160        assert!(!m.matches(&ipc("a.b.c.d")));
161    }
162
163    #[test]
164    fn matches_topic_subtree_for_acl() {
165        // Trailing `*` authorizes the whole subtree at any depth — the ACL
166        // semantics that let `astrid.v1.admin.*` cover every admin topic
167        // without enumerating each depth.
168        let m = TopicMatcher::new("astrid.v1.admin.*");
169        assert!(m.matches_topic("astrid.v1.admin.quota"));
170        assert!(m.matches_topic("astrid.v1.admin.agent.list"));
171        assert!(m.matches_topic("astrid.v1.admin.auth.pair.issue"));
172        // The prefix itself is not "under" the namespace.
173        assert!(!m.matches_topic("astrid.v1.admin"));
174        // A different namespace never matches.
175        assert!(!m.matches_topic("astrid.v1.registry.get"));
176
177        // Mid-segment `*` stays single-segment.
178        let mid = TopicMatcher::new("tool.v1.execute.*.result");
179        assert!(mid.matches_topic("tool.v1.execute.read_file.result"));
180        assert!(!mid.matches_topic("tool.v1.execute.a.b.result"));
181
182        // Exact (no `*`) stays equal-segment.
183        let exact = TopicMatcher::new("a.b.c");
184        assert!(exact.matches_topic("a.b.c"));
185        assert!(!exact.matches_topic("a.b.c.d"));
186    }
187
188    #[test]
189    fn matches_topic_enumerated_patterns_stay_compatible() {
190        // Backwards-compat: a depth-enumerated `*.*` / `*.*.*` pattern (as
191        // existing manifests declare today) still authorizes every topic it did
192        // under the old strict matcher — it now also matches deeper, harmlessly —
193        // so no capsule manifest needs to change when this lands.
194        let five = TopicMatcher::new("astrid.v1.admin.*.*");
195        assert!(five.matches_topic("astrid.v1.admin.agent.list")); // old 5-seg target
196        assert!(five.matches_topic("astrid.v1.admin.auth.pair")); // old 5-seg target
197        assert!(five.matches_topic("astrid.v1.admin.auth.pair.issue")); // now also deeper
198        assert!(!five.matches_topic("astrid.v1.admin.quota")); // 4-seg: below the pattern, as before
199
200        let six = TopicMatcher::new("astrid.v1.admin.*.*.*");
201        assert!(six.matches_topic("astrid.v1.admin.auth.pair.issue")); // old 6-seg target
202        assert!(!six.matches_topic("astrid.v1.admin.agent.list")); // 5-seg: below the pattern, as before
203    }
204
205    #[test]
206    fn topic_matcher_rejects_non_ipc() {
207        let m = TopicMatcher::new("a.*");
208        let lifecycle = Arc::new(AstridEvent::RuntimeStarted {
209            metadata: EventMetadata::new("test"),
210            version: "1".into(),
211        });
212        assert!(!m.matches(&lifecycle));
213    }
214
215    #[test]
216    fn principal_class_label_buckets() {
217        assert_eq!(principal_class_label(None), "system");
218        assert_eq!(principal_class_label(Some("alice")), "user");
219        assert_eq!(principal_class_label(Some("agent.scout")), "agent");
220        assert_eq!(principal_class_label(Some("agent:scout")), "agent");
221    }
222}