astrid_events/route/
matcher.rs1use std::sync::Arc;
6
7use crate::event::AstridEvent;
8
9#[derive(Debug, Clone)]
13pub struct TopicMatcher {
14 pattern: String,
15}
16
17impl TopicMatcher {
18 pub const MAX_TOPIC_DEPTH: usize = 20;
21
22 pub fn new(pattern: impl Into<String>) -> Self {
26 Self {
27 pattern: pattern.into(),
28 }
29 }
30
31 #[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 #[must_use]
44 pub fn matches_topic(&self, topic: &str) -> bool {
45 topic_pattern_matches(&self.pattern, topic)
46 }
47
48 #[must_use]
50 pub fn pattern(&self) -> &str {
51 &self.pattern
52 }
53}
54
55#[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 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 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#[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#[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 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 assert!(!m.matches_topic("astrid.v1.admin"));
174 assert!(!m.matches_topic("astrid.v1.registry.get"));
176
177 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 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 let five = TopicMatcher::new("astrid.v1.admin.*.*");
195 assert!(five.matches_topic("astrid.v1.admin.agent.list")); assert!(five.matches_topic("astrid.v1.admin.auth.pair")); assert!(five.matches_topic("astrid.v1.admin.auth.pair.issue")); assert!(!five.matches_topic("astrid.v1.admin.quota")); let six = TopicMatcher::new("astrid.v1.admin.*.*.*");
201 assert!(six.matches_topic("astrid.v1.admin.auth.pair.issue")); assert!(!six.matches_topic("astrid.v1.admin.agent.list")); }
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}