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()
262                        && pattern_idx + 1 >= pattern_segments.len();
263                }
264
265                let path_segment = path_segments[path_idx];
266                let matches = if self.case_sensitive {
267                    literal == path_segment
268                } else {
269                    literal.eq_ignore_ascii_case(path_segment)
270                };
271
272                if matches {
273                    self.match_segments(
274                        pattern_segments,
275                        path_segments,
276                        pattern_idx + 1,
277                        path_idx + 1,
278                        variables,
279                    )
280                } else {
281                    false
282                }
283            }
284        }
285    }
286
287    /// Match a pattern segment containing * or ? against a path segment
288    fn match_pattern(&self, pattern: &str, text: &str) -> bool {
289        let pattern_chars: Vec<char> = pattern.chars().collect();
290        let text_chars: Vec<char> = if self.case_sensitive {
291            text.chars().collect()
292        } else {
293            text.to_lowercase().chars().collect()
294        };
295
296        let pattern_lower: Vec<char> = if self.case_sensitive {
297            pattern_chars.clone()
298        } else {
299            pattern.to_lowercase().chars().collect()
300        };
301
302        self.match_pattern_chars(&pattern_lower, &text_chars, 0, 0)
303    }
304
305    /// Recursively match pattern characters against text characters
306    fn match_pattern_chars(
307        &self,
308        pattern: &[char],
309        text: &[char],
310        p_idx: usize,
311        t_idx: usize,
312    ) -> bool {
313        // Both exhausted - success
314        if p_idx >= pattern.len() && t_idx >= text.len() {
315            return true;
316        }
317
318        // Pattern exhausted but text remains - fail
319        if p_idx >= pattern.len() {
320            return false;
321        }
322
323        let p_char = pattern[p_idx];
324
325        match p_char {
326            '*' => {
327                // * matches zero or more characters
328                for skip in 0..=(text.len() - t_idx) {
329                    if self.match_pattern_chars(pattern, text, p_idx + 1, t_idx + skip) {
330                        return true;
331                    }
332                }
333                false
334            }
335            '?' => {
336                // ? matches exactly one character
337                if t_idx >= text.len() {
338                    return false;
339                }
340                self.match_pattern_chars(pattern, text, p_idx + 1, t_idx + 1)
341            }
342            _ => {
343                // Literal character
344                if t_idx >= text.len() {
345                    return false;
346                }
347                if p_char == text[t_idx] {
348                    self.match_pattern_chars(pattern, text, p_idx + 1, t_idx + 1)
349                } else {
350                    false
351                }
352            }
353        }
354    }
355}
356
357/// Builder for creating multiple AntMatchers with common configuration
358#[derive(Debug, Clone, Default)]
359pub struct AntMatcherBuilder {
360    case_sensitive: bool,
361}
362
363impl AntMatcherBuilder {
364    /// Create a new builder with default settings
365    pub fn new() -> Self {
366        Self {
367            case_sensitive: true,
368        }
369    }
370
371    /// Set case sensitivity (default: true)
372    pub fn case_sensitive(mut self, sensitive: bool) -> Self {
373        self.case_sensitive = sensitive;
374        self
375    }
376
377    /// Build a matcher with the given pattern
378    pub fn build(&self, pattern: &str) -> AntMatcher {
379        let mut matcher = AntMatcher::new(pattern);
380        if !self.case_sensitive {
381            matcher = matcher.case_insensitive();
382        }
383        matcher
384    }
385}
386
387/// Collection of AntMatchers for efficient path matching
388#[derive(Debug, Clone, Default)]
389pub struct AntMatchers {
390    matchers: Vec<AntMatcher>,
391}
392
393impl AntMatchers {
394    /// Create an empty collection
395    pub fn new() -> Self {
396        Self {
397            matchers: Vec::new(),
398        }
399    }
400
401    /// Add a pattern to the collection
402    #[allow(clippy::should_implement_trait)]
403    pub fn add(mut self, pattern: &str) -> Self {
404        self.matchers.push(AntMatcher::new(pattern));
405        self
406    }
407
408    /// Add multiple patterns
409    pub fn add_all(mut self, patterns: &[&str]) -> Self {
410        for pattern in patterns {
411            self.matchers.push(AntMatcher::new(pattern));
412        }
413        self
414    }
415
416    /// Check if any pattern matches the given path
417    pub fn matches(&self, path: &str) -> bool {
418        self.matchers.iter().any(|m| m.matches(path))
419    }
420
421    /// Get the first matching pattern, if any
422    pub fn find_match(&self, path: &str) -> Option<&AntMatcher> {
423        self.matchers.iter().find(|m| m.matches(path))
424    }
425
426    /// Get all matching patterns
427    pub fn find_all_matches(&self, path: &str) -> Vec<&AntMatcher> {
428        self.matchers.iter().filter(|m| m.matches(path)).collect()
429    }
430
431    /// Get the number of matchers
432    pub fn len(&self) -> usize {
433        self.matchers.len()
434    }
435
436    /// Check if empty
437    pub fn is_empty(&self) -> bool {
438        self.matchers.is_empty()
439    }
440}
441
442/// Extension trait for converting patterns to AntMatcher
443pub trait IntoAntMatcher {
444    fn into_ant_matcher(self) -> AntMatcher;
445}
446
447impl IntoAntMatcher for &str {
448    fn into_ant_matcher(self) -> AntMatcher {
449        AntMatcher::new(self)
450    }
451}
452
453impl IntoAntMatcher for String {
454    fn into_ant_matcher(self) -> AntMatcher {
455        AntMatcher::new(&self)
456    }
457}
458
459impl IntoAntMatcher for AntMatcher {
460    fn into_ant_matcher(self) -> AntMatcher {
461        self
462    }
463}
464
465// ============================================================================
466// Tests
467// ============================================================================
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn test_literal_match() {
475        let matcher = AntMatcher::new("/api/users");
476        assert!(matcher.matches("/api/users"));
477        // Trailing slashes are normalized (both patterns and paths)
478        assert!(matcher.matches("/api/users/"));
479        assert!(!matcher.matches("/api/user"));
480        assert!(!matcher.matches("/api/users/123"));
481    }
482
483    #[test]
484    fn test_single_wildcard() {
485        let matcher = AntMatcher::new("/users/*/profile");
486        assert!(matcher.matches("/users/123/profile"));
487        assert!(matcher.matches("/users/abc/profile"));
488        assert!(!matcher.matches("/users/profile"));
489        assert!(!matcher.matches("/users/123/456/profile"));
490    }
491
492    #[test]
493    fn test_double_wildcard() {
494        let matcher = AntMatcher::new("/api/**");
495        assert!(matcher.matches("/api/"));
496        assert!(matcher.matches("/api/users"));
497        assert!(matcher.matches("/api/users/123"));
498        assert!(matcher.matches("/api/users/123/posts"));
499        assert!(!matcher.matches("/other/path"));
500    }
501
502    #[test]
503    fn test_double_wildcard_middle() {
504        let matcher = AntMatcher::new("/api/**/edit");
505        assert!(matcher.matches("/api/edit"));
506        assert!(matcher.matches("/api/users/edit"));
507        assert!(matcher.matches("/api/users/123/edit"));
508        assert!(!matcher.matches("/api/users/123"));
509    }
510
511    #[test]
512    fn test_question_mark() {
513        let matcher = AntMatcher::new("/file?.txt");
514        assert!(matcher.matches("/file1.txt"));
515        assert!(matcher.matches("/fileA.txt"));
516        assert!(!matcher.matches("/file12.txt"));
517        assert!(!matcher.matches("/file.txt"));
518    }
519
520    #[test]
521    fn test_pattern_wildcard() {
522        let matcher = AntMatcher::new("/files/*.txt");
523        assert!(matcher.matches("/files/document.txt"));
524        assert!(matcher.matches("/files/test.txt"));
525        assert!(!matcher.matches("/files/document.pdf"));
526        assert!(!matcher.matches("/files/subdir/document.txt"));
527    }
528
529    #[test]
530    fn test_variable_extraction() {
531        let matcher = AntMatcher::new("/users/{id}");
532        let vars = matcher.extract_variables("/users/123");
533        assert!(vars.is_some());
534        let vars = vars.unwrap();
535        assert_eq!(vars.get("id"), Some(&"123".to_string()));
536    }
537
538    #[test]
539    fn test_multiple_variables() {
540        let matcher = AntMatcher::new("/users/{userId}/posts/{postId}");
541        let vars = matcher.extract_variables("/users/42/posts/99");
542        assert!(vars.is_some());
543        let vars = vars.unwrap();
544        assert_eq!(vars.get("userId"), Some(&"42".to_string()));
545        assert_eq!(vars.get("postId"), Some(&"99".to_string()));
546    }
547
548    #[test]
549    fn test_case_insensitive() {
550        let matcher = AntMatcher::new("/Api/Users").case_insensitive();
551        assert!(matcher.matches("/api/users"));
552        assert!(matcher.matches("/API/USERS"));
553        assert!(matcher.matches("/Api/Users"));
554    }
555
556    #[test]
557    fn test_root_path() {
558        let matcher = AntMatcher::new("/");
559        assert!(matcher.matches("/"));
560    }
561
562    #[test]
563    fn test_complex_pattern() {
564        let matcher = AntMatcher::new("/api/v*/users/**/profile");
565        assert!(matcher.matches("/api/v1/users/123/profile"));
566        assert!(matcher.matches("/api/v2/users/123/posts/456/profile"));
567        assert!(!matcher.matches("/api/users/123/profile"));
568    }
569
570    #[test]
571    fn test_ant_matchers_collection() {
572        let matchers = AntMatchers::new()
573            .add("/api/**")
574            .add("/public/**")
575            .add("/health");
576
577        assert!(matchers.matches("/api/users"));
578        assert!(matchers.matches("/public/images/logo.png"));
579        assert!(matchers.matches("/health"));
580        assert!(!matchers.matches("/private/data"));
581    }
582
583    #[test]
584    fn test_ant_matchers_find() {
585        let matchers = AntMatchers::new()
586            .add("/api/**")
587            .add("/admin/**");
588
589        let found = matchers.find_match("/api/users");
590        assert!(found.is_some());
591        assert_eq!(found.unwrap().pattern(), "/api/**");
592    }
593
594    #[test]
595    fn test_builder() {
596        let builder = AntMatcherBuilder::new().case_sensitive(false);
597        let matcher = builder.build("/API/USERS");
598        assert!(matcher.matches("/api/users"));
599    }
600
601    #[test]
602    fn test_into_ant_matcher() {
603        let m1: AntMatcher = "/api/**".into_ant_matcher();
604        let m2: AntMatcher = String::from("/users/*").into_ant_matcher();
605
606        assert!(m1.matches("/api/test"));
607        assert!(m2.matches("/users/123"));
608    }
609
610    #[test]
611    fn test_trailing_slash() {
612        let matcher = AntMatcher::new("/api/users/");
613        // Trailing slashes are normalized - both paths match
614        assert!(matcher.matches("/api/users")); // No trailing slash
615        assert!(matcher.matches("/api/users/")); // With trailing slash
616    }
617
618    #[test]
619    fn test_mixed_wildcards() {
620        let matcher = AntMatcher::new("/api/*/items/**");
621        assert!(matcher.matches("/api/v1/items/1"));
622        assert!(matcher.matches("/api/v1/items/1/2/3"));
623        assert!(matcher.matches("/api/v2/items/"));
624        assert!(!matcher.matches("/api/v1/v2/items/1"));
625    }
626
627    #[test]
628    fn test_pattern_segment_equality() {
629        assert_eq!(PatternSegment::SingleWildcard, PatternSegment::SingleWildcard);
630        assert_eq!(PatternSegment::DoubleWildcard, PatternSegment::DoubleWildcard);
631        assert_eq!(
632            PatternSegment::Literal("test".to_string()),
633            PatternSegment::Literal("test".to_string())
634        );
635    }
636}