| 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 |