core_policy/
path.rs

1//! Path matching using a custom wildcard implementation to avoid external dependencies.
2//!
3//! This module accepts wildcard forms commonly used in shells:
4//! - `*` - matches any sequence of characters except the path separator `/`
5//! - `?` - matches a single character
6//!
7//! **NOTE**: `**` is not supported in this implementation and is treated as a literal `*`.
8//!
9//! ## Security
10//!
11//! Matches paths using a non-recursive, zero-allocation algorithm.
12//! Safe for use with untrusted inputs (guaranteed O(N) time complexity).
13//!
14//! ## Examples
15//! - `/home/*/documents`
16//! - `/tmp/file?.txt`
17
18use crate::error::{PolicyError, Result};
19use crate::MAX_RESOURCE_PATTERN_LENGTH;
20use alloc::string::String;
21use serde::{Deserialize, Serialize};
22
23/// Path pattern with wildcard support.
24#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
25pub struct PathPattern {
26    pattern: String,
27}
28
29impl PathPattern {
30    /// Create a new path pattern
31    ///
32    /// # Errors
33    ///
34    /// Returns `PolicyError::PatternTooLong` if pattern exceeds `MAX_RESOURCE_PATTERN_LENGTH`
35    pub fn new(pattern: impl Into<String>) -> Result<Self> {
36        let pattern = pattern.into();
37
38        // T20 mitigation: Enforce maximum pattern length
39        if pattern.len() > MAX_RESOURCE_PATTERN_LENGTH {
40            return Err(PolicyError::PatternTooLong {
41                max: MAX_RESOURCE_PATTERN_LENGTH,
42                length: pattern.len(),
43            });
44        }
45
46        Ok(Self { pattern })
47    }
48
49    /// Create a new path pattern without validation (for internal use)
50    ///
51    /// **Warning**: Only use this when pattern is known to be valid and within limits
52    #[must_use]
53    pub(crate) fn new_unchecked(pattern: impl Into<String>) -> Self {
54        Self {
55            pattern: pattern.into(),
56        }
57    }
58
59    /// Check if a path matches this pattern
60    #[must_use]
61    pub fn matches(&self, path: &str) -> bool {
62        wildcard_match(&self.pattern, path)
63    }
64
65    /// Get the pattern string
66    #[must_use]
67    pub fn as_str(&self) -> &str {
68        &self.pattern
69    }
70}
71
72/// Iterative wildcard match with O(N) complexity.
73/// `*` doesn't match `/`. `**` is treated as `*`.
74///
75/// This implementation uses a two-pointer technique with backtracking
76/// to avoid recursion and guarantee linear time complexity.
77///
78/// # Algorithm
79///
80/// - Uses two indices: `p_idx` for pattern, `s_idx` for path
81/// - When encountering `*`, saves backtrack point
82/// - On mismatch, backtracks to last `*` and tries next position
83/// - `*` never matches `/` (path separator constraint)
84///
85/// # Complexity
86///
87/// - Time: O(N + M) where N = pattern length, M = path length
88/// - Space: O(1) - only uses local variables
89///
90/// # Safety
91///
92/// This function uses safe indexing via `.get().copied()` to eliminate
93/// any possibility of panic from out-of-bounds access.
94fn wildcard_match(pattern: &str, path: &str) -> bool {
95    let pattern_bytes = pattern.as_bytes();
96    let path_bytes = path.as_bytes();
97
98    let mut p_idx = 0;
99    let mut s_idx = 0;
100    let mut star_idx: Option<usize> = None;
101    let mut match_idx = 0;
102
103    while s_idx < path_bytes.len() {
104        // Get current path character safely
105        let Some(path_char) = path_bytes.get(s_idx).copied() else {
106            // Should not happen due to while condition, but be defensive
107            break;
108        };
109
110        if let Some(pattern_char) = pattern_bytes.get(p_idx).copied() {
111            match pattern_char {
112                b'?' => {
113                    // Match any single character
114                    p_idx += 1;
115                    s_idx += 1;
116                }
117                b'*' => {
118                    // Collapse consecutive '*' into one (treat '**' as '*')
119                    while pattern_bytes.get(p_idx).copied() == Some(b'*') {
120                        p_idx += 1;
121                    }
122                    // Save backtrack point
123                    star_idx = Some(p_idx);
124                    match_idx = s_idx;
125                }
126                c if c == path_char => {
127                    // Literal character match
128                    p_idx += 1;
129                    s_idx += 1;
130                }
131                _ => {
132                    // Mismatch - try to backtrack to last '*'
133                    if let Some(star_pos) = star_idx {
134                        // '*' doesn't match '/' - fail if we hit a separator
135                        if path_bytes.get(match_idx).copied() == Some(b'/') {
136                            return false;
137                        }
138                        // Backtrack: reset pattern to position after '*'
139                        p_idx = star_pos;
140                        match_idx += 1;
141                        s_idx = match_idx;
142                    } else {
143                        // No '*' to backtrack to - match fails
144                        return false;
145                    }
146                }
147            }
148        } else {
149            // Pattern exhausted but path remains - try backtracking
150            if let Some(star_pos) = star_idx {
151                // '*' doesn't match '/' - fail if we hit a separator
152                if match_idx >= path_bytes.len() || path_bytes.get(match_idx).copied() == Some(b'/')
153                {
154                    return false;
155                }
156                // Backtrack: reset pattern to position after '*'
157                p_idx = star_pos;
158                match_idx += 1;
159                s_idx = match_idx;
160            } else {
161                // No '*' to backtrack to - match fails
162                return false;
163            }
164        }
165    }
166
167    // After consuming all of the path, check if the pattern is exhausted
168    // We allow trailing '*' only if we've matched at least one character
169    // (i.e., if path is empty and pattern is "*", it should NOT match)
170    while pattern_bytes.get(p_idx).copied() == Some(b'*') {
171        // If the path was empty and we have a '*', this shouldn't match
172        if path_bytes.is_empty() {
173            return false;
174        }
175        p_idx += 1;
176    }
177
178    // Match succeeds if we've consumed both pattern and path
179    p_idx == pattern_bytes.len()
180}
181
182impl TryFrom<String> for PathPattern {
183    type Error = PolicyError;
184
185    fn try_from(s: String) -> Result<Self> {
186        Self::new(s)
187    }
188}
189
190impl TryFrom<&str> for PathPattern {
191    type Error = PolicyError;
192
193    fn try_from(s: &str) -> Result<Self> {
194        Self::new(s)
195    }
196}