Skip to main content

apcore_cli/
exposure.rs

1//! Module Exposure Filtering (FE-12).
2//!
3//! Provides declarative control over which discovered modules are exposed
4//! as CLI commands. Supports three modes: all, include (whitelist), and
5//! exclude (blacklist) with glob-pattern matching on module IDs.
6
7use regex::Regex;
8
9/// Compile a glob pattern into a [`Regex`].
10///
11/// - `*` matches a single dotted segment (no dots): `[^.]*`
12/// - `**` matches across segments (any characters including dots): `.+`
13/// - Literal text is matched exactly via regex escaping.
14fn 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
30/// Test whether a module_id matches a glob pattern.
31pub fn glob_match(module_id: &str, pattern: &str) -> bool {
32    compile_pattern(pattern).is_match(module_id)
33}
34
35/// Determines which modules are exposed as CLI commands.
36///
37/// Filtering modes:
38/// - `all`: every discovered module becomes a CLI command (default).
39/// - `include`: only modules matching at least one include pattern are exposed.
40/// - `exclude`: all modules exposed except those matching any exclude pattern.
41pub struct ExposureFilter {
42    /// Filter mode: "all" | "include" | "exclude".
43    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
58/// Modes accepted by `ExposureFilter`. Anything else is a configuration error.
59const VALID_MODES: &[&str] = &["all", "include", "exclude"];
60
61impl ExposureFilter {
62    /// Create a new exposure filter.
63    ///
64    /// Unknown `mode` values (anything not in `["all", "include", "exclude"]`)
65    /// are clamped to `"none"` with a `tracing::warn!` — fail-closed so a
66    /// typo'd mode hides every module rather than silently exposing all of
67    /// them. The companion `from_config` constructor returns Err on the same
68    /// input; both entry points now reject unknown modes consistently.
69    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    /// Return true if the module should be exposed as a CLI command.
95    ///
96    /// `"none"` and any unknown mode (which `new()` rewrites to `"none"`)
97    /// hide every module; `"all"` exposes every module; `"include"` and
98    /// `"exclude"` consult the compiled pattern lists.
99    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            // "none" and any unknown mode (new() rewrites unknown → none).
111            _ => false,
112        }
113    }
114
115    /// Partition module_ids into (exposed, hidden) lists.
116    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    /// Create from a serde_json::Value config.
130    ///
131    /// Expected structure:
132    /// ```json
133    /// { "expose": { "mode": "include", "include": ["admin.*"] } }
134    /// ```
135    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    // --- glob_match tests ---
185
186    #[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    // --- ExposureFilter tests ---
247
248    #[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        // Regression for review #12: ExposureFilter::new previously stored
324        // any string verbatim; is_exposed's `_ => true` arm exposed every
325        // module on a typo. New behavior: clamp unknown mode to "none" so
326        // every module is hidden — fail-closed for the security-relevant
327        // FE-12 "hide internal modules" use case.
328        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}