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