1use regex::Regex;
8
9fn compile_pattern(pattern: &str) -> Regex {
15 let sentinel = "\x00GLOB\x00";
16 let escaped = pattern.replace("**", sentinel);
17 let parts: Vec<&str> = escaped.split('*').collect();
18 let regex_parts: Vec<String> = parts
19 .iter()
20 .map(|p| {
21 let restored = p.replace(sentinel, "**");
22 regex::escape(&restored)
23 })
24 .collect();
25 let mut regex_str = regex_parts.join("[^.]*");
26 regex_str = regex_str.replace(r"\*\*", ".+");
27 Regex::new(&format!("^{regex_str}$")).expect("invalid exposure pattern regex")
28}
29
30pub fn glob_match(module_id: &str, pattern: &str) -> bool {
32 compile_pattern(pattern).is_match(module_id)
33}
34
35pub struct ExposureFilter {
42 pub mode: String,
44 compiled_include: Vec<Regex>,
45 compiled_exclude: Vec<Regex>,
46}
47
48impl Default for ExposureFilter {
49 fn default() -> Self {
50 Self {
51 mode: "all".to_string(),
52 compiled_include: Vec::new(),
53 compiled_exclude: Vec::new(),
54 }
55 }
56}
57
58const VALID_MODES: &[&str] = &["all", "include", "exclude"];
60
61impl ExposureFilter {
62 pub fn new(mode: &str, include: &[String], exclude: &[String]) -> Self {
70 let resolved_mode = if VALID_MODES.contains(&mode) {
71 mode.to_string()
72 } else {
73 tracing::warn!(
74 "Unknown ExposureFilter mode '{mode}' — defaulting to 'none' (no modules exposed). \
75 Valid modes: {VALID_MODES:?}"
76 );
77 "none".to_string()
78 };
79 let dedup = |patterns: &[String]| -> Vec<Regex> {
80 let mut seen = std::collections::HashSet::new();
81 patterns
82 .iter()
83 .filter(|p| seen.insert((*p).clone()))
84 .map(|p| compile_pattern(p))
85 .collect()
86 };
87 Self {
88 mode: resolved_mode,
89 compiled_include: dedup(include),
90 compiled_exclude: dedup(exclude),
91 }
92 }
93
94 pub fn is_exposed(&self, module_id: &str) -> bool {
100 match self.mode.as_str() {
101 "all" => true,
102 "include" => self
103 .compiled_include
104 .iter()
105 .any(|rx| rx.is_match(module_id)),
106 "exclude" => !self
107 .compiled_exclude
108 .iter()
109 .any(|rx| rx.is_match(module_id)),
110 _ => false,
112 }
113 }
114
115 pub fn filter_modules(&self, module_ids: &[String]) -> (Vec<String>, Vec<String>) {
117 let mut exposed = Vec::new();
118 let mut hidden = Vec::new();
119 for mid in module_ids {
120 if self.is_exposed(mid) {
121 exposed.push(mid.clone());
122 } else {
123 hidden.push(mid.clone());
124 }
125 }
126 (exposed, hidden)
127 }
128
129 pub fn from_config(config: &serde_json::Value) -> Result<Self, String> {
136 let expose = config.get("expose").unwrap_or(&serde_json::Value::Null);
137 if !expose.is_object() {
138 if !expose.is_null() {
139 tracing::warn!("Invalid 'expose' config (expected object), using mode: all.");
140 }
141 return Ok(Self::default());
142 }
143
144 let mode = expose.get("mode").and_then(|v| v.as_str()).unwrap_or("all");
145 if !["all", "include", "exclude"].contains(&mode) {
146 return Err(format!(
147 "Invalid expose mode: '{}'. Must be one of: all, include, exclude.",
148 mode
149 ));
150 }
151
152 let parse_list = |key: &str| -> Vec<String> {
153 match expose.get(key) {
154 Some(serde_json::Value::Array(arr)) => arr
155 .iter()
156 .filter_map(|v| {
157 let s = v.as_str().unwrap_or("");
158 if s.is_empty() {
159 tracing::warn!("Empty pattern in expose.{}, skipping.", key);
160 None
161 } else {
162 Some(s.to_string())
163 }
164 })
165 .collect(),
166 Some(_) => {
167 tracing::warn!("Invalid 'expose.{}' (expected array), ignoring.", key);
168 Vec::new()
169 }
170 None => Vec::new(),
171 }
172 };
173
174 let include = parse_list("include");
175 let exclude = parse_list("exclude");
176 Ok(Self::new(mode, &include, &exclude))
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
187 fn test_exact_match() {
188 assert!(glob_match("system.health", "system.health"));
189 }
190
191 #[test]
192 fn test_exact_no_partial() {
193 assert!(!glob_match("system.health.check", "system.health"));
194 }
195
196 #[test]
197 fn test_single_star_matches_one_segment() {
198 assert!(glob_match("admin.users", "admin.*"));
199 }
200
201 #[test]
202 fn test_single_star_not_across_dots() {
203 assert!(!glob_match("admin.users.list", "admin.*"));
204 }
205
206 #[test]
207 fn test_single_star_not_prefix_only() {
208 assert!(!glob_match("admin", "admin.*"));
209 }
210
211 #[test]
212 fn test_star_prefix() {
213 assert!(glob_match("product.get", "*.get"));
214 assert!(!glob_match("product.get.all", "*.get"));
215 }
216
217 #[test]
218 fn test_double_star_across_segments() {
219 assert!(glob_match("admin.users", "admin.**"));
220 assert!(glob_match("admin.users.list", "admin.**"));
221 }
222
223 #[test]
224 fn test_double_star_not_bare_prefix() {
225 assert!(!glob_match("admin", "admin.**"));
226 }
227
228 #[test]
229 fn test_bare_star() {
230 assert!(glob_match("standalone", "*"));
231 assert!(!glob_match("a.b", "*"));
232 }
233
234 #[test]
235 fn test_bare_double_star() {
236 assert!(glob_match("anything", "**"));
237 assert!(glob_match("a.b.c.d", "**"));
238 }
239
240 #[test]
241 fn test_literal_no_glob() {
242 assert!(glob_match("admin.users", "admin.users"));
243 assert!(!glob_match("admin.config", "admin.users"));
244 }
245
246 #[test]
249 fn test_mode_all() {
250 let f = ExposureFilter::default();
251 assert!(f.is_exposed("anything"));
252 }
253
254 #[test]
255 fn test_mode_include() {
256 let f = ExposureFilter::new("include", &["admin.*".into(), "jobs.*".into()], &[]);
257 assert!(f.is_exposed("admin.users"));
258 assert!(!f.is_exposed("webhooks.stripe"));
259 }
260
261 #[test]
262 fn test_mode_include_empty() {
263 let f = ExposureFilter::new("include", &[], &[]);
264 assert!(!f.is_exposed("anything"));
265 }
266
267 #[test]
268 fn test_mode_exclude() {
269 let f = ExposureFilter::new("exclude", &[], &["webhooks.*".into(), "internal.*".into()]);
270 assert!(f.is_exposed("admin.users"));
271 assert!(!f.is_exposed("webhooks.stripe"));
272 }
273
274 #[test]
275 fn test_mode_exclude_empty() {
276 let f = ExposureFilter::new("exclude", &[], &[]);
277 assert!(f.is_exposed("anything"));
278 }
279
280 #[test]
281 fn test_filter_modules() {
282 let f = ExposureFilter::new("include", &["admin.*".into()], &[]);
283 let (exposed, hidden) = f.filter_modules(&[
284 "admin.users".into(),
285 "admin.config".into(),
286 "webhooks.stripe".into(),
287 ]);
288 assert_eq!(exposed, vec!["admin.users", "admin.config"]);
289 assert_eq!(hidden, vec!["webhooks.stripe"]);
290 }
291
292 #[test]
293 fn test_from_config_include() {
294 let config: serde_json::Value = serde_json::json!({
295 "expose": {
296 "mode": "include",
297 "include": ["admin.*"]
298 }
299 });
300 let f = ExposureFilter::from_config(&config).unwrap();
301 assert_eq!(f.mode.as_str(), "include");
302 assert!(f.is_exposed("admin.users"));
303 assert!(!f.is_exposed("webhooks.stripe"));
304 }
305
306 #[test]
307 fn test_from_config_missing() {
308 let config = serde_json::json!({});
309 let f = ExposureFilter::from_config(&config).unwrap();
310 assert_eq!(f.mode.as_str(), "all");
311 }
312
313 #[test]
314 fn test_from_config_invalid_mode() {
315 let config = serde_json::json!({
316 "expose": { "mode": "whitelist" }
317 });
318 assert!(ExposureFilter::from_config(&config).is_err());
319 }
320
321 #[test]
322 fn test_new_unknown_mode_fails_closed() {
323 let f = ExposureFilter::new("whitelist", &[], &[]);
329 assert!(!f.is_exposed("any.module"));
330 assert!(!f.is_exposed("admin.users"));
331 }
332
333 #[test]
334 fn test_new_explicit_none_hides_all() {
335 let f = ExposureFilter::new("none", &[], &[]);
336 assert!(!f.is_exposed("anything"));
337 }
338}