返回 CodeWhale
matcher.rs
根目录 / crates / execpolicy / src / matcher.rs
1 //! Command matching helpers for execpolicy rules.
2
3 use std::collections::HashMap;
4 use std::sync::{Arc, Mutex, OnceLock};
5
6 use regex::Regex;
7
8 /// `pattern` compiled once as an anchored `*`-glob, then reused.
9 ///
10 /// Every other regex metacharacter is escaped, so `*` is the only wildcard
11 /// (matching any run of characters, newline included). `None` means the pattern
12 /// does not compile; callers treat that as "no match".
13 ///
14 /// The patterns come from configuration and are stable between edits, but the
15 /// callers run per shell execution and per hook event. Compiling here on first
16 /// use keeps the fast path free of `Regex::new` without changing what matches.
17 pub fn compiled_glob(pattern: &str) -> Option<Arc<Regex>> {
18 static CACHE: OnceLock<Mutex<HashMap<String, Option<Arc<Regex>>>>> = OnceLock::new();
19 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
20 let mut cache = cache
21 .lock()
22 .unwrap_or_else(|poisoned| poisoned.into_inner());
23 cache
24 .entry(pattern.to_string())
25 .or_insert_with(|| {
26 let escaped = regex::escape(pattern).replace(r"\*", ".*");
27 Regex::new(&format!("^{escaped}$")).ok().map(Arc::new)
28 })
29 .clone()
30 }
31
32 /// Normalize a command string by shlex parsing and re-joining tokens.
33 ///
34 /// Strips heredoc bodies first (#419) so a command like
35 /// `cat <<EOF > file.txt\nbody\nEOF` collapses to `cat > file.txt`
36 /// before pattern matching. Without this, an `auto_allow` pattern
37 /// of `cat > file.txt` would fail to match because shlex would
38 /// tokenize the body lines into the command.
39 pub fn normalize_command(command: &str) -> String {
40 let stripped = strip_heredoc_bodies(command);
41 if let Some(tokens) = shlex::split(&stripped) {
42 tokens.join(" ")
43 } else {
44 stripped
45 .split_whitespace()
46 .filter(|token| !token.is_empty())
47 .collect::<Vec<_>>()
48 .join(" ")
49 }
50 }
51
52 /// Strip heredoc bodies from a multi-line command string.
53 ///
54 /// Recognises the common forms:
55 ///
56 /// * `<<DELIM` — body until line equal to `DELIM`.
57 /// * `<<-DELIM` — body until line equal to `DELIM` (tabs stripped
58 /// in real shell; we keep the delimiter match the same).
59 /// * `<<'DELIM'` / `<<"DELIM"` — quoted delimiter; quotes peeled
60 /// for the closing match.
61 ///
62 /// The here-string operator `<<<` is intentionally not stripped —
63 /// its body is the next token on the same line, not separate lines,
64 /// and shlex tokenizes it correctly.
65 fn strip_heredoc_bodies(command: &str) -> String {
66 if !command.contains("<<") {
67 return command.to_string();
68 }
69 // Sidestep the here-string operator (`<<<`) by replacing it
70 // with a placeholder before running the heredoc regex, then
71 // restoring it after. Rust's `regex` crate doesn't support
72 // lookbehind, so we can't write "match `<<` only when not
73 // preceded by `<`" directly; this preprocessing achieves the
74 // same outcome.
75 const HERESTRING_PLACEHOLDER: &str = "\u{0001}HERESTRING\u{0001}";
76 let command_owned: String = command.replace("<<<", HERESTRING_PLACEHOLDER);
77 let command: &str = &command_owned;
78
79 // Lazy-init the heredoc-start regex. Allows whitespace / `-`
80 // between `<<` and the delimiter, accepts optional `'` / `"`
81 // around the delimiter name. The delimiter is a typical
82 // shell identifier (alphanumeric + underscore).
83 static HEREDOC_RE_INIT: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
84 let re = HEREDOC_RE_INIT.get_or_init(|| {
85 Regex::new(r#"<<-?\s*(?:['"]?)([A-Za-z_][A-Za-z0-9_]*)(?:['"]?)"#)
86 .expect("heredoc regex compiles")
87 });
88
89 let mut out = String::with_capacity(command.len());
90 let mut lines = command.lines();
91 while let Some(line) = lines.next() {
92 // Detect heredoc on this line, capture the delimiter, and
93 // strip the `<<DELIM` operator from the line so downstream
94 // tokenizers don't see it in the pattern. A single line can
95 // have multiple heredocs (rare but legal: `cmd <<A <<B`);
96 // we strip every match on the line and consume until the
97 // *last* delimiter (the matching shell behavior is to stack
98 // them, but for pattern-match purposes they all collapse).
99 let mut delim: Option<String> = None;
100 let mut redacted = line.to_string();
101 for cap in re.captures_iter(line) {
102 // Strip the entire `<<DELIM` text from the line.
103 let whole = cap.get(0).map_or("", |m| m.as_str());
104 redacted = redacted.replace(whole, "");
105 // Track the last-seen delimiter for body consumption.
106 delim = cap.get(1).map(|m| m.as_str().to_string());
107 }
108 // Trim any double-spaces left after stripping.
109 let cleaned = redacted
110 .split_whitespace()
111 .filter(|t| !t.is_empty())
112 .collect::<Vec<_>>()
113 .join(" ");
114 out.push_str(&cleaned);
115 out.push('\n');
116 if let Some(d) = delim {
117 // Skip body lines until we hit the matching delimiter.
118 for body_line in lines.by_ref() {
119 if body_line.trim() == d {
120 break;
121 }
122 }
123 }
124 }
125 // Restore the here-string operator we hid before regex matching.
126 out.replace(HERESTRING_PLACEHOLDER, "<<<")
127 }
128
129 /// Return true if the pattern matches the command.
130 ///
131 /// Patterns support `*` wildcards that match any substring.
132 pub fn pattern_matches(pattern: &str, command: &str) -> bool {
133 let pattern = normalize_command(pattern);
134 let command = normalize_command(command);
135
136 if pattern == "*" {
137 return true;
138 }
139
140 compiled_glob(&pattern).is_some_and(|re| re.is_match(&command))
141 }
142
143 #[cfg(test)]
144 mod tests {
145 use super::*;
146
147 #[test]
148 fn test_normalize_command() {
149 assert_eq!(normalize_command("git status"), "git status");
150 assert_eq!(
151 normalize_command("git \"log --oneline\""),
152 "git log --oneline"
153 );
154 }
155
156 #[test]
157 fn test_pattern_matches() {
158 assert!(pattern_matches("git status", "git status"));
159 assert!(pattern_matches("git log *", "git log --oneline"));
160 assert!(pattern_matches("cargo *", "cargo test --all"));
161 assert!(!pattern_matches("git push --force", "git push origin main"));
162 }
163
164 #[test]
165 fn strip_heredoc_strips_simple_body() {
166 let cmd = "cat <<EOF > file.txt\nhello\nworld\nEOF";
167 let stripped = super::strip_heredoc_bodies(cmd);
168 // Body lines `hello` and `world` are gone; the delimiter
169 // `EOF` line is also consumed.
170 assert!(!stripped.contains("hello"));
171 assert!(!stripped.contains("world"));
172 // The redirect target survives.
173 assert!(stripped.contains("> file.txt"));
174 }
175
176 #[test]
177 fn strip_heredoc_handles_dash_form() {
178 // `<<-EOF` strips leading tabs in a real shell; for our
179 // matching purposes we still want the delimiter consumed.
180 let cmd = "cat <<-EOF > file.txt\n\tbody\nEOF";
181 let stripped = super::strip_heredoc_bodies(cmd);
182 assert!(!stripped.contains("body"));
183 assert!(stripped.contains("> file.txt"));
184 }
185
186 #[test]
187 fn strip_heredoc_handles_quoted_delimiter() {
188 let cmd = "cat <<'END_OF_FILE' > out\nliteral $vars\nEND_OF_FILE";
189 let stripped = super::strip_heredoc_bodies(cmd);
190 assert!(!stripped.contains("literal $vars"));
191 assert!(stripped.contains("> out"));
192 }
193
194 #[test]
195 fn strip_heredoc_leaves_non_heredoc_commands_intact() {
196 let cmd = "echo hello && ls";
197 // Early-return path: no `<<` in the input, so the original
198 // string flows through unchanged (no trailing newline added).
199 assert_eq!(super::strip_heredoc_bodies(cmd), "echo hello && ls");
200 }
201
202 #[test]
203 fn strip_heredoc_does_not_touch_here_string_operator() {
204 // `<<<` is here-string; the body is on the same line.
205 // shlex handles it fine — we shouldn't try to strip
206 // anything because there's no body following on later lines.
207 let cmd = "grep foo <<< \"some text\"";
208 let stripped = super::strip_heredoc_bodies(cmd);
209 // Output keeps the `<<<` — content not stripped.
210 assert!(stripped.contains("<<<"));
211 assert!(stripped.contains("some text"));
212 }
213
214 #[test]
215 fn normalize_command_strips_heredoc_for_pattern_matching() {
216 // The end-to-end goal: a user's `auto_allow = ["cat > file.txt"]`
217 // pattern matches the heredoc form too.
218 let normalized = normalize_command("cat <<EOF > file.txt\nbody\nEOF");
219 assert!(pattern_matches("cat > file.txt", &normalized));
220 }
221
222 #[test]
223 fn compiled_glob_is_compiled_once_per_pattern() {
224 // The per-shell-execution and per-hook-event callers rely on this
225 // returning the same compiled program rather than rebuilding it.
226 let first = compiled_glob("mcp__*").expect("glob compiles");
227 let second = compiled_glob("mcp__*").expect("glob compiles");
228 assert!(std::sync::Arc::ptr_eq(&first, &second));
229 assert!(first.is_match("mcp__github__search"));
230 assert!(!first.is_match("read_file"));
231 }
232
233 #[test]
234 fn compiled_glob_escapes_every_metacharacter_except_star() {
235 // `regex::escape` is what makes `a.b` a literal while `*` stays a
236 // wildcard — the same contract `pattern_matches` has always had.
237 let literal = compiled_glob("a.b").expect("glob compiles");
238 assert!(literal.is_match("a.b"));
239 assert!(!literal.is_match("axb"));
240
241 let wildcard = compiled_glob("a*b").expect("glob compiles");
242 assert!(wildcard.is_match("ab"));
243 assert!(wildcard.is_match("a middle b"));
244 }
245 }
246
246 lines RUST