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}