Skip to main content

actix_security_core/http/security/
ant_matcher.rs

1//! Ant-style Path Matcher
2//!
3//! Provides Spring-style Ant path matching for URL patterns.
4//! This is an alternative to regex-based matching, providing more intuitive
5//! pattern syntax commonly used in Spring Security.
6//!
7//! # Pattern Syntax
8//!
9//! - `?` matches exactly one character
10//! - `*` matches zero or more characters within a path segment
11//! - `**` matches zero or more path segments
12//! - `{name}` captures a named path variable
13//!
14//! # Examples
15//!
16//! ```rust
17//! use actix_security_core::http::security::ant_matcher::AntMatcher;
18//!
19//! // Match any path under /api/
20//! let matcher = AntMatcher::new("/api/**");
21//! assert!(matcher.matches("/api/users"));
22//! assert!(matcher.matches("/api/users/123/profile"));
23//!
24//! // Match single segment wildcard
25//! let matcher = AntMatcher::new("/users/*/profile");
26//! assert!(matcher.matches("/users/123/profile"));
27//! assert!(!matcher.matches("/users/123/456/profile"));
28//!
29//! // Match single character
30//! let matcher = AntMatcher::new("/file?.txt");
31//! assert!(matcher.matches("/file1.txt"));
32//! assert!(!matcher.matches("/file12.txt"));
33//! ```
34//!
35//! # Spring Equivalent
36//!
37//! `org.springframework.util.AntPathMatcher`
38
39use std::collections::HashMap;
40
41/// Ant-style path matcher
42///
43/// Provides pattern matching similar to Spring's AntPathMatcher.
44#[derive(Debug, Clone)]
45pub struct AntMatcher {
46    pattern: String,
47    segments: Vec<PatternSegment>,
48    case_sensitive: bool,
49}
50
51/// A segment of the pattern
52#[derive(Debug, Clone, PartialEq)]
53enum PatternSegment {
54    /// Literal text (no wildcards)
55    Literal(String),
56    /// Single segment wildcard (*)
57    SingleWildcard,
58    /// Multi-segment wildcard (**)
59    DoubleWildcard,
60    /// Pattern with wildcards (*, ?)
61    Pattern(String),
62    /// Named path variable ({name})
63    Variable(String),
64}
65
66impl AntMatcher {
67    /// Create a new AntMatcher with the given pattern
68    ///
69    /// # Pattern Syntax
70    /// - `?` matches exactly one character
71    /// - `*` matches zero or more characters within a path segment
72    /// - `**` matches zero or more path segments
73    /// - `{name}` captures a named path variable
74    ///
75    /// # Example
76    /// ```rust
77    /// use actix_security_core::http::security::ant_matcher::AntMatcher;
78    ///
79    /// let matcher = AntMatcher::new("/api/**");
80    /// assert!(matcher.matches("/api/users"));
81    /// ```
82    pub fn new(pattern: &str) -> Self {
83        let segments = Self::parse_pattern(pattern);
84        Self {
85            pattern: pattern.to_string(),
86            segments,
87            case_sensitive: true,
88        }
89    }
90
91    /// Create a case-insensitive matcher
92    pub fn case_insensitive(mut self) -> Self {
93        self.case_sensitive = false;
94        self
95    }
96
97    /// Get the original pattern string
98    pub fn pattern(&self) -> &str {
99        &self.pattern
100    }
101
102    /// Parse pattern into segments
103    fn parse_pattern(pattern: &str) -> Vec<PatternSegment> {
104        let mut segments = Vec::new();
105        let trimmed = pattern.trim_start_matches('/');
106
107        if trimmed.is_empty() {
108            return vec![PatternSegment::Literal(String::new())];
109        }
110
111        for part in trimmed.split('/') {
112            let segment = if part == "**" {
113                PatternSegment::DoubleWildcard
114            } else if part == "*" {
115                PatternSegment::SingleWildcard
116            } else if part.starts_with('{') && part.ends_with('}') {
117                let var_name = part[1..part.len() - 1].to_string();
118                PatternSegment::Variable(var_name)
119            } else if part.contains('*') || part.contains('?') {
120                PatternSegment::Pattern(part.to_string())
121            } else {
122                PatternSegment::Literal(part.to_string())
123            };
124            segments.push(segment);
125        }
126
127        segments
128    }
129
130    /// Check if the given path matches this pattern
131    ///
132    /// # Example
133    /// ```rust
134    /// use actix_security_core::http::security::ant_matcher::AntMatcher;
135    ///
136    /// let matcher = AntMatcher::new("/users/*/profile");
137    /// assert!(matcher.matches("/users/123/profile"));
138    /// assert!(!matcher.matches("/users/profile"));
139    /// ```
140    pub fn matches(&self, path: &str) -> bool {
141        self.do_match(path, &mut None)
142    }
143
144    /// Check if the path matches and extract path variables
145    ///
146    /// # Example
147    /// ```rust
148    /// use actix_security_core::http::security::ant_matcher::AntMatcher;
149    ///
150    /// let matcher = AntMatcher::new("/users/{id}/posts/{postId}");
151    /// let vars = matcher.extract_variables("/users/123/posts/456");
152    ///
153    /// assert!(vars.is_some());
154    /// let vars = vars.unwrap();
155    /// assert_eq!(vars.get("id"), Some(&"123".to_string()));
156    /// assert_eq!(vars.get("postId"), Some(&"456".to_string()));
157    /// ```
158    pub fn extract_variables(&self, path: &str) -> Option<HashMap<String, String>> {
159        let mut variables = HashMap::new();
160        if self.do_match(path, &mut Some(&mut variables)) {
161            Some(variables)
162        } else {
163            None
164        }
165    }
166
167    /// Internal matching function
168    fn do_match(&self, path: &str, variables: &mut Option<&mut HashMap<String, String>>) -> bool {
169        let path_segments: Vec<&str> = path
170            .trim_start_matches('/')
171            .split('/')
172            .filter(|s| !s.is_empty())
173            .collect();
174
175        self.match_segments(&self.segments, &path_segments, 0, 0, variables)
176    }
177
178    /// Recursively match pattern segments against path segments
179    fn match_segments(
180        &self,
181        pattern_segments: &[PatternSegment],
182        path_segments: &[&str],
183        pattern_idx: usize,
184        path_idx: usize,
185        variables: &mut Option<&mut HashMap<String, String>>,
186    ) -> bool {
187        // Both exhausted - success
188        if pattern_idx >= pattern_segments.len() && path_idx >= path_segments.len() {
189            return true;
190        }
191
192        // Pattern exhausted but path remains - fail
193        if pattern_idx >= pattern_segments.len() {
194            return false;
195        }
196
197        let pattern_segment = &pattern_segments[pattern_idx];
198
199        match pattern_segment {
200            PatternSegment::DoubleWildcard => {
201                // ** matches zero or more path segments
202                // Try matching 0, 1, 2, ... path segments
203                for skip in 0..=(path_segments.len() - path_idx) {
204                    if self.match_segments(
205                        pattern_segments,
206                        path_segments,
207                        pattern_idx + 1,
208                        path_idx + skip,
209                        variables,
210                    ) {
211                        return true;
212                    }
213                }
214                false
215            }
216
217            PatternSegment::SingleWildcard | PatternSegment::Variable(_) => {
218                // * or {var} matches exactly one segment
219                if path_idx >= path_segments.len() {
220                    return false;
221                }
222
223                // Store variable if capturing
224                if let PatternSegment::Variable(name) = pattern_segment {
225                    if let Some(ref mut vars) = variables {
226                        vars.insert(name.clone(), path_segments[path_idx].to_string());
227                    }
228                }
229
230                self.match_segments(
231                    pattern_segments,
232                    path_segments,
233                    pattern_idx + 1,
234                    path_idx + 1,
235                    variables,
236                )
237            }
238
239            PatternSegment::Pattern(pattern) => {
240                // Pattern with wildcards
241                if path_idx >= path_segments.len() {
242                    return false;
243                }
244
245                if self.match_pattern(pattern, path_segments[path_idx]) {
246                    self.match_segments(
247                        pattern_segments,
248                        path_segments,
249                        pattern_idx + 1,
250                        path_idx + 1,
251                        variables,
252                    )
253                } else {
254                    false
255                }
256            }
257
258            PatternSegment::Literal(literal) => {
259                if path_idx >= path_segments.len() {
260                    // Check for empty literal (matches root)
261                    return literal.is_empty() && pattern_idx + 1 >= pattern_segments.len();
262                }
263
264                let path_segment = path_segments[path_idx];
265                let matches = if self.case_sensitive {
266                    literal == path_segment
267                } else {
268                    literal.eq_ignore_ascii_case(path_segment)
269                };
270
271                if matches {
272                    self.match_segments(
273                        pattern_segments,
274                        path_segments,
275                        pattern_idx + 1,
276                        path_idx + 1,
277                        variables,
278                    )
279                } else {
280                    false
281                }
282            }
283        }
284    }
285
286    /// Match a pattern segment containing * or ? against a path segment
287    fn match_pattern(&self, pattern: &str, text: &str) -> bool {
288        let pattern_chars: Vec<char> = pattern.chars().collect();
289        let text_chars: Vec<char> = if self.case_sensitive {
290            text.chars().collect()
291        } else {
292            text.to_lowercase().chars().collect()
293        };
294
295        let pattern_lower: Vec<char> = if self.case_sensitive {
296            pattern_chars.clone()
297        } else {
298            pattern.to_lowercase().chars().collect()
299        };
300
301        self.match_pattern_chars(&pattern_lower, &text_chars, 0, 0)
302    }
303
304    /// Recursively match pattern characters against text characters
305    fn match_pattern_chars(
306        &self,
307        pattern: &[char],
308        text: &[char],
309        p_idx: usize,
310        t_idx: usize,
311    ) -> bool {
312        // Both exhausted - success
313        if p_idx >= pattern.len() && t_idx >= text.len() {
314            return true;
315        }
316
317        // Pattern exhausted but text remains - fail
318        if p_idx >= pattern.len() {
319            return false;
320        }
321
322        let p_char = pattern[p_idx];
323
324        match p_char {
325            '*' => {
326                // * matches zero or more characters
327                for skip in 0..=(text.len() - t_idx) {
328                    if self.match_pattern_chars(pattern, text, p_idx + 1, t_idx + skip) {
329                        return true;
330                    }
331                }
332                false
333            }
334            '?' => {
335                // ? matches exactly one character
336                if t_idx >= text.len() {
337                    return false;
338                }
339                self.match_pattern_chars(pattern, text, p_idx + 1, t_idx + 1)
340            }
341            _ => {
342                // Literal character
343                if t_idx >= text.len() {
344                    return false;
345                }
346                if p_char == text[t_idx] {
347                    self.match_pattern_chars(pattern, text, p_idx + 1, t_idx + 1)
348                } else {
349                    false
350                }
351            }
352        }
353    }
354}
355
356/// Builder for creating multiple AntMatchers with common configuration
357#[derive(Debug, Clone, Default)]
358pub struct AntMatcherBuilder {
359    case_sensitive: bool,
360}
361
362impl AntMatcherBuilder {
363    /// Create a new builder with default settings
364    pub fn new() -> Self {
365        Self {
366            case_sensitive: true,
367        }
368    }
369
370    /// Set case sensitivity (default: true)
371    pub fn case_sensitive(mut self, sensitive: bool) -> Self {
372        self.case_sensitive = sensitive;
373        self
374    }
375
376    /// Build a matcher with the given pattern
377    pub fn build(&self, pattern: &str) -> AntMatcher {
378        let mut matcher = AntMatcher::new(pattern);
379        if !self.case_sensitive {
380            matcher = matcher.case_insensitive();
381        }
382        matcher
383    }
384}
385
386/// Collection of AntMatchers for efficient path matching
387#[derive(Debug, Clone, Default)]
388pub struct AntMatchers {
389    matchers: Vec<AntMatcher>,
390}
391
392impl AntMatchers {
393    /// Create an empty collection
394    pub fn new() -> Self {
395        Self {
396            matchers: Vec::new(),
397        }
398    }
399
400    /// Add a pattern to the collection
401    #[allow(clippy::should_implement_trait)]
402    pub fn add(mut self, pattern: &str) -> Self {
403        self.matchers.push(AntMatcher::new(pattern));
404        self
405    }
406
407    /// Add multiple patterns
408    pub fn add_all(mut self, patterns: &[&str]) -> Self {
409        for pattern in patterns {
410            self.matchers.push(AntMatcher::new(pattern));
411        }
412        self
413    }
414
415    /// Check if any pattern matches the given path
416    pub fn matches(&self, path: &str) -> bool {
417        self.matchers.iter().any(|m| m.matches(path))
418    }
419
420    /// Get the first matching pattern, if any
421    pub fn find_match(&self, path: &str) -> Option<&AntMatcher> {
422        self.matchers.iter().find(|m| m.matches(path))
423    }
424
425    /// Get all matching patterns
426    pub fn find_all_matches(&self, path: &str) -> Vec<&AntMatcher> {
427        self.matchers.iter().filter(|m| m.matches(path)).collect()
428    }
429
430    /// Get the number of matchers
431    pub fn len(&self) -> usize {
432        self.matchers.len()
433    }
434
435    /// Check if empty
436    pub fn is_empty(&self) -> bool {
437        self.matchers.is_empty()
438    }
439}
440
441/// Extension trait for converting patterns to AntMatcher
442pub trait IntoAntMatcher {
443    fn into_ant_matcher(self) -> AntMatcher;
444}
445
446impl IntoAntMatcher for &str {
447    fn into_ant_matcher(self) -> AntMatcher {
448        AntMatcher::new(self)
449    }
450}
451
452impl IntoAntMatcher for String {
453    fn into_ant_matcher(self) -> AntMatcher {
454        AntMatcher::new(&self)
455    }
456}
457
458impl IntoAntMatcher for AntMatcher {
459    fn into_ant_matcher(self) -> AntMatcher {
460        self
461    }
462}
463
464// ============================================================================
465// Tests
466// ============================================================================
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn test_literal_match() {
474        let matcher = AntMatcher::new("/api/users");
475        assert!(matcher.matches("/api/users"));
476        // Trailing slashes are normalized (both patterns and paths)
477        assert!(matcher.matches("/api/users/"));
478        assert!(!matcher.matches("/api/user"));
479        assert!(!matcher.matches("/api/users/123"));
480    }
481
482    #[test]
483    fn test_single_wildcard() {
484        let matcher = AntMatcher::new("/users/*/profile");
485        assert!(matcher.matches("/users/123/profile"));
486        assert!(matcher.matches("/users/abc/profile"));
487        assert!(!matcher.matches("/users/profile"));
488        assert!(!matcher.matches("/users/123/456/profile"));
489    }
490
491    #[test]
492    fn test_double_wildcard() {
493        let matcher = AntMatcher::new("/api/**");
494        assert!(matcher.matches("/api/"));
495        assert!(matcher.matches("/api/users"));
496        assert!(matcher.matches("/api/users/123"));
497        assert!(matcher.matches("/api/users/123/posts"));
498        assert!(!matcher.matches("/other/path"));
499    }
500
501    #[test]
502    fn test_double_wildcard_middle() {
503        let matcher = AntMatcher::new("/api/**/edit");
504        assert!(matcher.matches("/api/edit"));
505        assert!(matcher.matches("/api/users/edit"));
506        assert!(matcher.matches("/api/users/123/edit"));
507        assert!(!matcher.matches("/api/users/123"));
508    }
509
510    #[test]
511    fn test_question_mark() {
512        let matcher = AntMatcher::new("/file?.txt");
513        assert!(matcher.matches("/file1.txt"));
514        assert!(matcher.matches("/fileA.txt"));
515        assert!(!matcher.matches("/file12.txt"));
516        assert!(!matcher.matches("/file.txt"));
517    }
518
519    #[test]
520    fn test_pattern_wildcard() {
521        let matcher = AntMatcher::new("/files/*.txt");
522        assert!(matcher.matches("/files/document.txt"));
523        assert!(matcher.matches("/files/test.txt"));
524        assert!(!matcher.matches("/files/document.pdf"));
525        assert!(!matcher.matches("/files/subdir/document.txt"));
526    }
527
528    #[test]
529    fn test_variable_extraction() {
530        let matcher = AntMatcher::new("/users/{id}");
531        let vars = matcher.extract_variables("/users/123");
532        assert!(vars.is_some());
533        let vars = vars.unwrap();
534        assert_eq!(vars.get("id"), Some(&"123".to_string()));
535    }
536
537    #[test]
538    fn test_multiple_variables() {
539        let matcher = AntMatcher::new("/users/{userId}/posts/{postId}");
540        let vars = matcher.extract_variables("/users/42/posts/99");
541        assert!(vars.is_some());
542        let vars = vars.unwrap();
543        assert_eq!(vars.get("userId"), Some(&"42".to_string()));
544        assert_eq!(vars.get("postId"), Some(&"99".to_string()));
545    }
546
547    #[test]
548    fn test_case_insensitive() {
549        let matcher = AntMatcher::new("/Api/Users").case_insensitive();
550        assert!(matcher.matches("/api/users"));
551        assert!(matcher.matches("/API/USERS"));
552        assert!(matcher.matches("/Api/Users"));
553    }
554
555    #[test]
556    fn test_root_path() {
557        let matcher = AntMatcher::new("/");
558        assert!(matcher.matches("/"));
559    }
560
561    #[test]
562    fn test_complex_pattern() {
563        let matcher = AntMatcher::new("/api/v*/users/**/profile");
564        assert!(matcher.matches("/api/v1/users/123/profile"));
565        assert!(matcher.matches("/api/v2/users/123/posts/456/profile"));
566        assert!(!matcher.matches("/api/users/123/profile"));
567    }
568
569    #[test]
570    fn test_ant_matchers_collection() {
571        let matchers = AntMatchers::new()
572            .add("/api/**")
573            .add("/public/**")
574            .add("/health");
575
576        assert!(matchers.matches("/api/users"));
577        assert!(matchers.matches("/public/images/logo.png"));
578        assert!(matchers.matches("/health"));
579        assert!(!matchers.matches("/private/data"));
580    }
581
582    #[test]
583    fn test_ant_matchers_find() {
584        let matchers = AntMatchers::new().add("/api/**").add("/admin/**");
585
586        let found = matchers.find_match("/api/users");
587        assert!(found.is_some());
588        assert_eq!(found.unwrap().pattern(), "/api/**");
589    }
590
591    #[test]
592    fn test_builder() {
593        let builder = AntMatcherBuilder::new().case_sensitive(false);
594        let matcher = builder.build("/API/USERS");
595        assert!(matcher.matches("/api/users"));
596    }
597
598    #[test]
599    fn test_into_ant_matcher() {
600        let m1: AntMatcher = "/api/**".into_ant_matcher();
601        let m2: AntMatcher = String::from("/users/*").into_ant_matcher();
602
603        assert!(m1.matches("/api/test"));
604        assert!(m2.matches("/users/123"));
605    }
606
607    #[test]
608    fn test_trailing_slash() {
609        let matcher = AntMatcher::new("/api/users/");
610        // Trailing slashes are normalized - both paths match
611        assert!(matcher.matches("/api/users")); // No trailing slash
612        assert!(matcher.matches("/api/users/")); // With trailing slash
613    }
614
615    #[test]
616    fn test_mixed_wildcards() {
617        let matcher = AntMatcher::new("/api/*/items/**");
618        assert!(matcher.matches("/api/v1/items/1"));
619        assert!(matcher.matches("/api/v1/items/1/2/3"));
620        assert!(matcher.matches("/api/v2/items/"));
621        assert!(!matcher.matches("/api/v1/v2/items/1"));
622    }
623
624    #[test]
625    fn test_pattern_segment_equality() {
626        assert_eq!(
627            PatternSegment::SingleWildcard,
628            PatternSegment::SingleWildcard
629        );
630        assert_eq!(
631            PatternSegment::DoubleWildcard,
632            PatternSegment::DoubleWildcard
633        );
634        assert_eq!(
635            PatternSegment::Literal("test".to_string()),
636            PatternSegment::Literal("test".to_string())
637        );
638    }
639}