Skip to main content

wami_core/arn/
matching.rs

1//! ARN Pattern Matching with Variable Substitution and Double-Star Globbing
2//!
3//! This module provides enhanced pattern matching for WAMI ARN policy evaluation:
4//!
5//! - **Variable substitution**: `${tenant}`, `${principal}`, `${service}` resolved
6//!   at evaluation time from a [`MatchContext`].
7//! - **Double-star (`**`)**: matches zero or more path segments (separated by `/`).
8//! - **Single-star (`*`)**: matches any characters within a single segment.
9//!
10//! # Examples
11//!
12//! ```
13//! use wami_core::arn::matching::{MatchContext, matches_arn_pattern};
14//!
15//! let ctx = MatchContext {
16//!     tenant: Some("12345678".into()),
17//!     principal: Some("user/alice".into()),
18//!     service: Some("iam".into()),
19//! };
20//!
21//! // Variable substitution
22//! assert!(matches_arn_pattern(
23//!     "arn:wami:iam:${tenant}:wami:*:user/*",
24//!     "arn:wami:iam:12345678:wami:999:user/alice",
25//!     &ctx,
26//! ));
27//!
28//! // Double-star matches multi-segment paths
29//! assert!(matches_arn_pattern(
30//!     "arn:wami:hub:12345678:wami:*:space/le-zinc/**",
31//!     "arn:wami:hub:12345678:wami:999:space/le-zinc/db/menu",
32//!     &ctx,
33//! ));
34//! ```
35
36use std::collections::HashMap;
37
38/// Context for variable resolution during ARN pattern matching.
39#[derive(Debug, Clone, Default)]
40pub struct MatchContext {
41    /// The caller's tenant ID (resolves `${tenant}`)
42    pub tenant: Option<String>,
43    /// The caller's principal (resolves `${principal}`)
44    pub principal: Option<String>,
45    /// The service name (resolves `${service}`)
46    pub service: Option<String>,
47}
48
49impl MatchContext {
50    /// Create a new empty context.
51    pub fn new() -> Self {
52        Self::default()
53    }
54
55    /// Build a variable map for substitution.
56    fn to_vars(&self) -> HashMap<&str, &str> {
57        let mut vars = HashMap::new();
58        if let Some(ref t) = self.tenant {
59            vars.insert("tenant", t.as_str());
60        }
61        if let Some(ref p) = self.principal {
62            vars.insert("principal", p.as_str());
63        }
64        if let Some(ref s) = self.service {
65            vars.insert("service", s.as_str());
66        }
67        vars
68    }
69}
70
71/// Build a [`MatchContext`] from a [`WamiContext`](crate::context::WamiContext).
72impl From<&crate::context::WamiContext> for MatchContext {
73    fn from(ctx: &crate::context::WamiContext) -> Self {
74        Self {
75            tenant: Some(ctx.tenant_path().as_string()),
76            principal: Some(ctx.caller_arn().resource.resource_id.clone()),
77            service: Some(ctx.caller_arn().service.to_string()),
78        }
79    }
80}
81
82/// Substitute `${var}` references in a pattern string.
83///
84/// Unknown variables are left as-is (no match will occur against a literal `${xxx}`).
85fn substitute_variables(pattern: &str, vars: &HashMap<&str, &str>) -> String {
86    let mut result = String::with_capacity(pattern.len());
87    let mut chars = pattern.chars().peekable();
88
89    while let Some(c) = chars.next() {
90        if c == '$' && chars.peek() == Some(&'{') {
91            chars.next(); // consume '{'
92            let mut var_name = String::new();
93            for vc in chars.by_ref() {
94                if vc == '}' {
95                    break;
96                }
97                var_name.push(vc);
98            }
99            if let Some(&val) = vars.get(var_name.as_str()) {
100                result.push_str(val);
101            } else {
102                // Unknown variable — leave literal (will fail match)
103                result.push_str("${");
104                result.push_str(&var_name);
105                result.push('}');
106            }
107        } else {
108            result.push(c);
109        }
110    }
111
112    result
113}
114
115/// Match an ARN string against a pattern with variable substitution and globbing.
116///
117/// # Pattern Syntax
118///
119/// - `*` — matches any characters within a single path segment (no `/`)
120/// - `**` — matches zero or more path segments including `/`
121/// - `${tenant}` — substituted from context
122/// - `${principal}` — substituted from context
123/// - `${service}` — substituted from context
124///
125/// # Arguments
126///
127/// - `pattern` — the policy resource pattern (e.g., `arn:wami:iam:${tenant}:wami:*:space/**`)
128/// - `text` — the concrete ARN string to match against
129/// - `ctx` — context for variable substitution
130pub fn matches_arn_pattern(pattern: &str, text: &str, ctx: &MatchContext) -> bool {
131    // Step 1: substitute variables
132    let vars = ctx.to_vars();
133    let resolved = substitute_variables(pattern, &vars);
134
135    // Step 2: match with glob support
136    glob_match(&resolved, text)
137}
138
139/// Match a resource pattern against a resource string (no variable substitution).
140///
141/// This is the raw glob matcher supporting `*` and `**`.
142///
143/// `*` matches any characters except `/`.
144/// `**` matches zero or more path segments (including `/`).
145/// A trailing `/**` also matches the path without the trailing `/` (e.g.,
146/// `a/b/**` matches both `a/b` and `a/b/c/d`).
147pub fn glob_match(pattern: &str, text: &str) -> bool {
148    // Fast paths
149    if pattern == "*" || pattern == "**" {
150        return true;
151    }
152    if pattern == text {
153        return true;
154    }
155    if !pattern.contains('*') {
156        return pattern == text;
157    }
158
159    if glob_match_recursive(pattern.as_bytes(), text.as_bytes()) {
160        return true;
161    }
162
163    // Special case: pattern ends with `/**` — also match without the trailing segment.
164    // e.g., `space/le-zinc/**` should match `space/le-zinc`.
165    if let Some(prefix) = pattern.strip_suffix("/**") {
166        return text == prefix || glob_match_recursive(prefix.as_bytes(), text.as_bytes());
167    }
168
169    false
170}
171
172/// Iterative glob matching with `*` (single-segment) and `**` (multi-segment).
173///
174/// `*` matches any characters except `/`.
175/// `**` matches any characters including `/` (zero or more segments).
176fn glob_match_recursive(pattern: &[u8], text: &[u8]) -> bool {
177    let mut pi = 0usize;
178    let mut ti = 0usize;
179
180    // Backtracking point for last `**`
181    let mut dstar_pi: i64 = -1;
182    let mut dstar_ti: i64 = -1;
183
184    // Backtracking point for last single `*`
185    let mut star_pi: i64 = -1;
186    let mut star_ti: i64 = -1;
187
188    while ti < text.len() || pi < pattern.len() {
189        if pi < pattern.len() {
190            // Check for `**`
191            if pi + 1 < pattern.len() && pattern[pi] == b'*' && pattern[pi + 1] == b'*' {
192                dstar_pi = pi as i64;
193                dstar_ti = ti as i64;
194                pi += 2;
195                // Skip trailing `/` after `**`
196                if pi < pattern.len() && pattern[pi] == b'/' {
197                    pi += 1;
198                }
199                // Reset single-star backtracking
200                star_pi = -1;
201                star_ti = -1;
202                continue;
203            }
204
205            // Check for single `*`
206            if pattern[pi] == b'*' {
207                star_pi = pi as i64;
208                star_ti = ti as i64;
209                pi += 1;
210                continue;
211            }
212
213            // Literal match
214            if ti < text.len() && pattern[pi] == text[ti] {
215                pi += 1;
216                ti += 1;
217                continue;
218            }
219        }
220
221        // Backtrack to single `*` (matches any char except `/`)
222        if star_pi >= 0 {
223            let st = star_ti as usize;
224            if st < text.len() && text[st] != b'/' {
225                star_ti += 1;
226                ti = star_ti as usize;
227                pi = star_pi as usize + 1;
228                continue;
229            }
230            // Single * can't cross `/`, fall through to ** backtrack
231        }
232
233        // Backtrack to `**` (matches anything including `/`)
234        if dstar_pi >= 0 {
235            dstar_ti += 1;
236            let st = dstar_ti as usize;
237            if st <= text.len() {
238                ti = st;
239                pi = dstar_pi as usize + 2;
240                if pi < pattern.len() && pattern[pi] == b'/' {
241                    pi += 1;
242                }
243                // Reset single-star backtracking
244                star_pi = -1;
245                star_ti = -1;
246                continue;
247            }
248        }
249
250        return false;
251    }
252
253    true
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    // ── Variable Substitution ─────────────────────────────────
261
262    #[test]
263    fn test_substitute_tenant() {
264        let vars: HashMap<&str, &str> = [("tenant", "12345678")].into();
265        assert_eq!(
266            substitute_variables("arn:wami:iam:${tenant}:wami:*:user/*", &vars),
267            "arn:wami:iam:12345678:wami:*:user/*"
268        );
269    }
270
271    #[test]
272    fn test_substitute_multiple_vars() {
273        let vars: HashMap<&str, &str> = [("tenant", "12345678"), ("service", "iam")].into();
274        assert_eq!(
275            substitute_variables("arn:wami:${service}:${tenant}:wami:*:user/*", &vars),
276            "arn:wami:iam:12345678:wami:*:user/*"
277        );
278    }
279
280    #[test]
281    fn test_substitute_unknown_var_left_as_is() {
282        let vars: HashMap<&str, &str> = HashMap::new();
283        assert_eq!(
284            substitute_variables("arn:wami:iam:${unknown}:wami:*:user/*", &vars),
285            "arn:wami:iam:${unknown}:wami:*:user/*"
286        );
287    }
288
289    #[test]
290    fn test_substitute_no_vars() {
291        let vars: HashMap<&str, &str> = HashMap::new();
292        assert_eq!(
293            substitute_variables("arn:wami:iam:*:user/*", &vars),
294            "arn:wami:iam:*:user/*"
295        );
296    }
297
298    // ── Single Star Matching ──────────────────────────────────
299
300    #[test]
301    fn test_glob_exact_match() {
302        assert!(glob_match("hello", "hello"));
303        assert!(!glob_match("hello", "world"));
304    }
305
306    #[test]
307    fn test_glob_star_all() {
308        assert!(glob_match("*", "anything"));
309        assert!(glob_match("*", ""));
310    }
311
312    #[test]
313    fn test_glob_single_star_within_segment() {
314        // * matches within a segment (no /)
315        assert!(glob_match("user/*", "user/alice"));
316        assert!(glob_match("user/*", "user/bob"));
317        assert!(glob_match("*/alice", "user/alice"));
318    }
319
320    #[test]
321    fn test_glob_single_star_does_not_cross_slash() {
322        // * should NOT match across /
323        assert!(!glob_match("space/*/db", "space/le-zinc/sub/db"));
324        assert!(glob_match("space/*/db", "space/le-zinc/db"));
325    }
326
327    #[test]
328    fn test_glob_star_prefix_suffix() {
329        assert!(glob_match("iam:*", "iam:GetUser"));
330        assert!(glob_match("iam:Get*", "iam:GetUser"));
331        assert!(!glob_match("iam:Delete*", "iam:GetUser"));
332    }
333
334    // ── Double Star Matching ──────────────────────────────────
335
336    #[test]
337    fn test_glob_double_star_matches_everything() {
338        assert!(glob_match("**", "anything/with/slashes"));
339        assert!(glob_match("**", ""));
340    }
341
342    #[test]
343    fn test_glob_double_star_multi_segment() {
344        assert!(glob_match("space/le-zinc/**", "space/le-zinc/db/menu"));
345        assert!(glob_match(
346            "space/le-zinc/**",
347            "space/le-zinc/db/menu/items"
348        ));
349        assert!(glob_match("space/le-zinc/**", "space/le-zinc"));
350        assert!(!glob_match("space/le-zinc/**", "space/other/db"));
351    }
352
353    #[test]
354    fn test_glob_double_star_in_middle() {
355        assert!(glob_match(
356            "arn:wami:**/user/*",
357            "arn:wami:iam:12345678:wami:999:user/alice"
358        ));
359        assert!(glob_match("a/**/z", "a/b/c/d/z"));
360        assert!(glob_match("a/**/z", "a/z"));
361    }
362
363    #[test]
364    fn test_glob_double_star_at_start() {
365        assert!(glob_match(
366            "**/user/alice",
367            "arn:wami:iam:123:wami:999:user/alice"
368        ));
369    }
370
371    // ── Full ARN Pattern Matching ─────────────────────────────
372
373    #[test]
374    fn test_arn_pattern_with_variables() {
375        let ctx = MatchContext {
376            tenant: Some("12345678".into()),
377            principal: Some("alice".into()),
378            service: Some("iam".into()),
379        };
380
381        assert!(matches_arn_pattern(
382            "arn:wami:iam:${tenant}:wami:*:user/*",
383            "arn:wami:iam:12345678:wami:999:user/alice",
384            &ctx,
385        ));
386    }
387
388    #[test]
389    fn test_arn_pattern_tenant_mismatch() {
390        let ctx = MatchContext {
391            tenant: Some("99999999".into()),
392            ..Default::default()
393        };
394
395        assert!(!matches_arn_pattern(
396            "arn:wami:iam:${tenant}:wami:*:user/*",
397            "arn:wami:iam:12345678:wami:999:user/alice",
398            &ctx,
399        ));
400    }
401
402    #[test]
403    fn test_arn_pattern_double_star_space_scoping() {
404        let ctx = MatchContext {
405            tenant: Some("12345678".into()),
406            ..Default::default()
407        };
408
409        // Space-scoped policy: allow all resources under le-zinc
410        let pattern = "arn:wami:hub:${tenant}:wami:*:space/le-zinc/**";
411
412        assert!(matches_arn_pattern(
413            pattern,
414            "arn:wami:hub:12345678:wami:999:space/le-zinc/db/menu",
415            &ctx,
416        ));
417
418        assert!(matches_arn_pattern(
419            pattern,
420            "arn:wami:hub:12345678:wami:999:space/le-zinc/persona/chef",
421            &ctx,
422        ));
423
424        assert!(!matches_arn_pattern(
425            pattern,
426            "arn:wami:hub:12345678:wami:999:space/other-space/db/menu",
427            &ctx,
428        ));
429    }
430
431    #[test]
432    fn test_arn_pattern_no_context() {
433        let ctx = MatchContext::default();
434
435        // Without variables, pattern matching still works
436        assert!(matches_arn_pattern(
437            "arn:wami:iam:*:wami:*:user/*",
438            "arn:wami:iam:12345678:wami:999:user/alice",
439            &ctx,
440        ));
441    }
442
443    #[test]
444    fn test_arn_pattern_wildcard_all() {
445        let ctx = MatchContext::default();
446        assert!(matches_arn_pattern("*", "anything", &ctx));
447    }
448
449    #[test]
450    fn test_arn_pattern_exact_match() {
451        let ctx = MatchContext::default();
452        assert!(matches_arn_pattern(
453            "arn:wami:iam:12345678:wami:999:user/alice",
454            "arn:wami:iam:12345678:wami:999:user/alice",
455            &ctx,
456        ));
457    }
458
459    // ── Edge Cases ────────────────────────────────────────────
460
461    #[test]
462    fn test_glob_empty_pattern_empty_text() {
463        assert!(glob_match("", ""));
464    }
465
466    #[test]
467    fn test_glob_empty_pattern_nonempty_text() {
468        assert!(!glob_match("", "something"));
469    }
470
471    #[test]
472    fn test_glob_consecutive_stars() {
473        // *** should behave like **
474        assert!(glob_match("a/**/*", "a/b/c"));
475    }
476
477    #[test]
478    fn test_glob_star_at_colon_boundary() {
479        // ARN segments separated by : — * still matches within a segment
480        assert!(glob_match(
481            "arn:wami:iam:*:wami:*:user/alice",
482            "arn:wami:iam:12345678:wami:999:user/alice"
483        ));
484    }
485
486    #[test]
487    fn test_backward_compat_existing_patterns() {
488        // Ensure existing * patterns from authorization.rs still work
489        assert!(glob_match(
490            "arn:wami:iam:*:user/*",
491            "arn:wami:iam:12345678:wami:999:user/alice"
492        ));
493        assert!(glob_match("*.example.com", "api.example.com"));
494        assert!(glob_match("test-*-prod", "test-api-prod"));
495        assert!(!glob_match(
496            "arn:*:role/*",
497            "arn:wami:iam:12345678:wami:999:user/alice"
498        ));
499    }
500}