Skip to main content

uira_security/permissions/
pattern.rs

1//! Glob pattern matching with path expansion
2//!
3//! Provides pattern matching for file paths and permission strings
4//! with support for common path expansions.
5
6use globset::{GlobBuilder, GlobMatcher};
7use std::path::PathBuf;
8
9/// Error type for pattern operations
10#[derive(Debug, thiserror::Error)]
11pub enum PatternError {
12    #[error("invalid glob pattern: {0}")]
13    InvalidPattern(#[from] globset::Error),
14
15    #[error("path expansion failed: {0}")]
16    ExpansionFailed(String),
17}
18
19/// A compiled glob pattern for matching
20#[derive(Debug, Clone)]
21pub struct Pattern {
22    /// Original pattern string
23    original: String,
24    /// Expanded pattern string
25    expanded: String,
26    /// Compiled glob matcher
27    matcher: GlobMatcher,
28}
29
30impl Pattern {
31    /// Create a new pattern from a string
32    ///
33    /// Supports the following expansions:
34    /// - `~/` → User home directory
35    /// - `$HOME/` → User home directory
36    /// - `$CWD/` → Current working directory
37    pub fn new(pattern: &str) -> Result<Self, PatternError> {
38        let expanded = expand_path(pattern)?;
39        let glob = GlobBuilder::new(&expanded)
40            .literal_separator(true)
41            .build()?;
42        let matcher = glob.compile_matcher();
43
44        Ok(Self {
45            original: pattern.to_string(),
46            expanded,
47            matcher,
48        })
49    }
50
51    /// Check if this pattern matches a path
52    pub fn matches(&self, path: &str) -> bool {
53        self.matcher.is_match(path)
54    }
55
56    /// Check if this pattern matches a path with expansion
57    pub fn matches_expanded(&self, path: &str) -> bool {
58        if let Ok(expanded_path) = expand_path(path) {
59            self.matcher.is_match(&expanded_path)
60        } else {
61            self.matcher.is_match(path)
62        }
63    }
64
65    /// Get the original pattern string
66    pub fn original(&self) -> &str {
67        &self.original
68    }
69
70    /// Get the expanded pattern string
71    pub fn expanded(&self) -> &str {
72        &self.expanded
73    }
74}
75
76/// Expand path variables in a string
77///
78/// Supported expansions:
79/// - `~/` → User home directory
80/// - `$HOME/` or `$HOME` → User home directory
81/// - `$CWD/` or `$CWD` → Current working directory
82pub fn expand_path(path: &str) -> Result<String, PatternError> {
83    let mut result = path.to_string();
84
85    // Expand ~/ to home directory
86    if result.starts_with("~/") {
87        let home = dirs::home_dir()
88            .ok_or_else(|| PatternError::ExpansionFailed("could not find home directory".into()))?;
89        result = format!("{}{}", home.display(), &result[1..]);
90    }
91
92    // Expand $HOME
93    if result.contains("$HOME") {
94        let home = dirs::home_dir()
95            .ok_or_else(|| PatternError::ExpansionFailed("could not find home directory".into()))?;
96        result = result.replace("$HOME", &home.display().to_string());
97    }
98
99    // Expand $CWD
100    if result.contains("$CWD") {
101        let cwd = std::env::current_dir()
102            .map_err(|e| PatternError::ExpansionFailed(format!("could not get cwd: {}", e)))?;
103        result = result.replace("$CWD", &cwd.display().to_string());
104    }
105
106    Ok(result)
107}
108
109/// Normalize a path for matching
110///
111/// Removes trailing slashes and normalizes separators
112pub fn normalize_path(path: &str) -> String {
113    let mut result = path.replace('\\', "/");
114    while result.ends_with('/') && result.len() > 1 {
115        result.pop();
116    }
117    result
118}
119
120/// Check if a path matches any of the given patterns
121pub fn matches_any(path: &str, patterns: &[Pattern]) -> bool {
122    let normalized = normalize_path(path);
123    patterns.iter().any(|p| p.matches(&normalized))
124}
125
126/// Create a pattern that matches a directory and all its contents
127pub fn directory_pattern(dir: &str) -> Result<Pattern, PatternError> {
128    let normalized = normalize_path(dir);
129    Pattern::new(&format!("{}/**", normalized))
130}
131
132/// Get the home directory as a PathBuf
133pub fn home_dir() -> Option<PathBuf> {
134    dirs::home_dir()
135}
136
137/// Get the current working directory as a PathBuf
138pub fn current_dir() -> std::io::Result<PathBuf> {
139    std::env::current_dir()
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_simple_pattern() {
148        let pattern = Pattern::new("src/**/*.rs").unwrap();
149        assert!(pattern.matches("src/main.rs"));
150        assert!(pattern.matches("src/lib/mod.rs"));
151        assert!(!pattern.matches("tests/test.rs"));
152    }
153
154    #[test]
155    fn test_exact_pattern() {
156        let pattern = Pattern::new("Cargo.toml").unwrap();
157        assert!(pattern.matches("Cargo.toml"));
158        assert!(!pattern.matches("cargo.toml"));
159        assert!(!pattern.matches("src/Cargo.toml"));
160    }
161
162    #[test]
163    fn test_wildcard_pattern() {
164        let pattern = Pattern::new("*.rs").unwrap();
165        assert!(pattern.matches("main.rs"));
166        assert!(pattern.matches("lib.rs"));
167        // Note: Single * in globset can match path separators depending on config
168        // For strict single-segment matching, use [^/]* pattern
169    }
170
171    #[test]
172    fn test_double_wildcard() {
173        let pattern = Pattern::new("**/*.rs").unwrap();
174        assert!(pattern.matches("main.rs"));
175        assert!(pattern.matches("src/main.rs"));
176        assert!(pattern.matches("src/deep/nested/file.rs"));
177    }
178
179    #[test]
180    fn test_expand_home() {
181        let result = expand_path("~/test").unwrap();
182        assert!(!result.starts_with("~/"));
183        assert!(result.contains("test"));
184    }
185
186    #[test]
187    fn test_expand_cwd() {
188        let result = expand_path("$CWD/test").unwrap();
189        assert!(!result.contains("$CWD"));
190        assert!(result.contains("test"));
191    }
192
193    #[test]
194    fn test_normalize_path() {
195        assert_eq!(normalize_path("src/"), "src");
196        assert_eq!(normalize_path("src//"), "src");
197        assert_eq!(normalize_path("src\\lib"), "src/lib");
198        assert_eq!(normalize_path("/"), "/");
199    }
200
201    #[test]
202    fn test_directory_pattern() {
203        let pattern = directory_pattern("src").unwrap();
204        assert!(pattern.matches("src/main.rs"));
205        assert!(pattern.matches("src/lib/mod.rs"));
206        assert!(!pattern.matches("tests/test.rs"));
207    }
208
209    #[test]
210    fn test_matches_any() {
211        let patterns = vec![
212            Pattern::new("src/**/*.rs").unwrap(),
213            Pattern::new("tests/**/*.rs").unwrap(),
214        ];
215        assert!(matches_any("src/main.rs", &patterns));
216        assert!(matches_any("tests/test.rs", &patterns));
217        assert!(!matches_any("docs/readme.md", &patterns));
218    }
219
220    #[test]
221    fn test_invalid_pattern() {
222        let result = Pattern::new("[invalid");
223        assert!(result.is_err());
224    }
225}