返回 CodeWhale
bash_arity.rs
根目录 / crates / execpolicy / src / bash_arity.rs
1 //! Bash arity dictionary for command-prefix allow rule matching.
2 //!
3 //! [`BashArityDict`] maps a command prefix (space-separated, lowercase) to the
4 //! number of positional (non-flag) words, *including the base command word*,
5 //! that form the canonical prefix.
6 //!
7 //! ## Invariant
8 //!
9 //! Flags (tokens starting with `-`) are **never** counted toward arity.
10 //! `auto_allow = ["git status"]` must match `git status -s` and
11 //! `git status --porcelain`, but **not** `git push`.
12 //!
13 //! ## Coverage
14 //!
15 //! 30+ common tools are covered across: git, npm, yarn, pnpm, cargo, docker,
16 //! kubectl, go, python/pip, gh, rustup, deno, bun, aws, terraform, make,
17 //! and more.
18
19 /// Static arity table: `(prefix, arity)`.
20 ///
21 /// Arity is the total number of *positional* tokens (including the base
22 /// command) that form the canonical prefix. For example:
23 ///
24 /// * `("git status", 2)` — 2 positional tokens: `git` + `status`.
25 /// * `("npm run", 3)` — 3 positional tokens: `npm` + `run` + `<script>`.
26 /// * `("make", 1)` — only the base command, no sub-command.
27 pub static BASH_ARITY_TABLE: &[(&str, u8)] = &[
28 // ── git ──────────────────────────────────────────────────────────────────
29 ("git add", 2),
30 ("git am", 2),
31 ("git apply", 2),
32 ("git bisect", 2),
33 ("git blame", 2),
34 ("git branch", 2),
35 ("git cat-file", 2),
36 ("git checkout", 2),
37 ("git cherry-pick", 2),
38 ("git clean", 2),
39 ("git clone", 2),
40 ("git commit", 2),
41 ("git config", 2),
42 ("git describe", 2),
43 ("git diff", 2),
44 ("git fetch", 2),
45 ("git format-patch", 2),
46 ("git grep", 2),
47 ("git init", 2),
48 ("git log", 2),
49 ("git ls-files", 2),
50 ("git merge", 2),
51 ("git mv", 2),
52 ("git notes", 2),
53 ("git pull", 2),
54 ("git push", 2),
55 ("git rebase", 2),
56 ("git reflog", 2),
57 ("git remote", 2),
58 ("git reset", 2),
59 ("git restore", 2),
60 ("git revert", 2),
61 ("git rm", 2),
62 ("git show", 2),
63 ("git stash", 2),
64 ("git status", 2),
65 ("git submodule", 2),
66 ("git switch", 2),
67 ("git tag", 2),
68 ("git worktree", 2),
69 // ── npm ──────────────────────────────────────────────────────────────────
70 ("npm audit", 2),
71 ("npm build", 2),
72 ("npm cache", 2),
73 ("npm ci", 2),
74 ("npm dedupe", 2),
75 ("npm fund", 2),
76 ("npm help", 2),
77 ("npm info", 2),
78 ("npm init", 2),
79 ("npm install", 2),
80 ("npm link", 2),
81 ("npm list", 2),
82 ("npm ls", 2),
83 ("npm outdated", 2),
84 ("npm pack", 2),
85 ("npm prune", 2),
86 ("npm publish", 2),
87 ("npm rebuild", 2),
88 ("npm run", 3),
89 ("npm start", 2),
90 ("npm stop", 2),
91 ("npm test", 2),
92 ("npm uninstall", 2),
93 ("npm update", 2),
94 ("npm version", 2),
95 ("npm view", 2),
96 // ── yarn ─────────────────────────────────────────────────────────────────
97 ("yarn add", 2),
98 ("yarn audit", 2),
99 ("yarn build", 2),
100 ("yarn install", 2),
101 ("yarn run", 3),
102 ("yarn start", 2),
103 ("yarn test", 2),
104 ("yarn upgrade", 2),
105 ("yarn workspace", 3),
106 // ── pnpm ─────────────────────────────────────────────────────────────────
107 ("pnpm add", 2),
108 ("pnpm build", 2),
109 ("pnpm install", 2),
110 ("pnpm run", 3),
111 ("pnpm start", 2),
112 ("pnpm test", 2),
113 ("pnpm update", 2),
114 // ── cargo ────────────────────────────────────────────────────────────────
115 ("cargo add", 2),
116 ("cargo bench", 2),
117 ("cargo build", 2),
118 ("cargo check", 2),
119 ("cargo clean", 2),
120 ("cargo clippy", 2),
121 ("cargo doc", 2),
122 ("cargo fix", 2),
123 ("cargo fmt", 2),
124 ("cargo generate", 2),
125 ("cargo install", 2),
126 ("cargo metadata", 2),
127 ("cargo package", 2),
128 ("cargo publish", 2),
129 ("cargo remove", 2),
130 ("cargo run", 2),
131 ("cargo search", 2),
132 ("cargo test", 2),
133 ("cargo tree", 2),
134 ("cargo uninstall", 2),
135 ("cargo update", 2),
136 ("cargo yank", 2),
137 // ── docker ───────────────────────────────────────────────────────────────
138 ("docker build", 2),
139 ("docker compose", 3),
140 ("docker container", 3),
141 ("docker cp", 2),
142 ("docker exec", 2),
143 ("docker image", 3),
144 ("docker images", 2),
145 ("docker inspect", 2),
146 ("docker kill", 2),
147 ("docker logs", 2),
148 ("docker network", 3),
149 ("docker ps", 2),
150 ("docker pull", 2),
151 ("docker push", 2),
152 ("docker rm", 2),
153 ("docker rmi", 2),
154 ("docker run", 2),
155 ("docker start", 2),
156 ("docker stop", 2),
157 ("docker system", 3),
158 ("docker tag", 2),
159 ("docker volume", 3),
160 // ── kubectl ──────────────────────────────────────────────────────────────
161 ("kubectl apply", 2),
162 ("kubectl create", 3),
163 ("kubectl delete", 3),
164 ("kubectl describe", 3),
165 ("kubectl exec", 2),
166 ("kubectl explain", 2),
167 ("kubectl get", 3),
168 ("kubectl label", 2),
169 ("kubectl logs", 2),
170 ("kubectl patch", 2),
171 ("kubectl port-forward", 2),
172 ("kubectl rollout", 3),
173 ("kubectl scale", 2),
174 ("kubectl set", 2),
175 ("kubectl top", 3),
176 // ── go ───────────────────────────────────────────────────────────────────
177 ("go build", 2),
178 ("go clean", 2),
179 ("go env", 2),
180 ("go fmt", 2),
181 ("go generate", 2),
182 ("go get", 2),
183 ("go install", 2),
184 ("go list", 2),
185 ("go mod", 3),
186 ("go run", 2),
187 ("go test", 2),
188 ("go vet", 2),
189 ("go work", 3),
190 // ── python / pip ─────────────────────────────────────────────────────────
191 ("pip install", 2),
192 ("pip uninstall", 2),
193 ("pip list", 2),
194 ("pip show", 2),
195 ("pip freeze", 2),
196 ("pip3 install", 2),
197 ("pip3 uninstall", 2),
198 ("pip3 list", 2),
199 ("pip3 show", 2),
200 ("python -m", 3),
201 ("python3 -m", 3),
202 // ── make / cmake ─────────────────────────────────────────────────────────
203 ("make", 1),
204 ("cmake", 1),
205 // ── gh (GitHub CLI) ──────────────────────────────────────────────────────
206 ("gh pr", 3),
207 ("gh issue", 3),
208 ("gh repo", 3),
209 ("gh release", 3),
210 ("gh workflow", 3),
211 ("gh run", 3),
212 ("gh secret", 3),
213 // ── rustup ───────────────────────────────────────────────────────────────
214 ("rustup default", 2),
215 ("rustup install", 2),
216 ("rustup show", 2),
217 ("rustup target", 3),
218 ("rustup toolchain", 3),
219 ("rustup update", 2),
220 // ── deno / bun ───────────────────────────────────────────────────────────
221 ("deno run", 2),
222 ("deno test", 2),
223 ("deno fmt", 2),
224 ("deno lint", 2),
225 ("bun add", 2),
226 ("bun build", 2),
227 ("bun install", 2),
228 ("bun run", 3),
229 ("bun test", 2),
230 ("npx", 2),
231 // ── aws CLI ──────────────────────────────────────────────────────────────
232 ("aws s3", 3),
233 ("aws ec2", 3),
234 ("aws iam", 3),
235 ("aws lambda", 3),
236 ("aws cloudformation", 3),
237 ("aws ecs", 3),
238 ("aws eks", 3),
239 ("aws rds", 3),
240 ("aws sts", 3),
241 ("aws configure", 2),
242 // ── terraform ────────────────────────────────────────────────────────────
243 ("terraform init", 2),
244 ("terraform plan", 2),
245 ("terraform apply", 2),
246 ("terraform destroy", 2),
247 ("terraform validate", 2),
248 ("terraform output", 2),
249 ("terraform state", 3),
250 ("terraform workspace", 3),
251 // ── helm ─────────────────────────────────────────────────────────────────
252 ("helm install", 2),
253 ("helm upgrade", 2),
254 ("helm uninstall", 2),
255 ("helm list", 2),
256 ("helm repo", 3),
257 ("helm status", 2),
258 ("helm template", 2),
259 ];
260
261 /// Arity dictionary for bash command-prefix allow rules.
262 ///
263 /// Provides arity-aware prefix extraction so that `auto_allow = ["git status"]`
264 /// correctly matches `git status -s` and `git status --porcelain` without
265 /// also matching `git push`.
266 ///
267 /// # Example
268 ///
269 /// ```rust
270 /// use codewhale_execpolicy::bash_arity::BashArityDict;
271 ///
272 /// let dict = BashArityDict::new();
273 /// assert_eq!(dict.classify(&["git", "status", "-s"]), "git status");
274 /// assert_eq!(dict.classify(&["git", "push", "origin"]), "git push");
275 /// assert_eq!(dict.classify(&["npm", "run", "dev"]), "npm run dev");
276 /// assert_eq!(dict.classify(&["ls", "-la"]), "ls");
277 /// ```
278 #[derive(Debug, Clone)]
279 pub struct BashArityDict {
280 /// Internal table sorted longest-prefix-first for greedy matching.
281 entries: Vec<(&'static str, u8)>,
282 }
283
284 impl BashArityDict {
285 /// Construct a new dictionary pre-loaded with [`BASH_ARITY_TABLE`].
286 #[must_use]
287 pub fn new() -> Self {
288 let mut entries: Vec<(&'static str, u8)> = BASH_ARITY_TABLE.to_vec();
289 // Longest prefix first so greedy matching works correctly.
290 entries.sort_by_key(|entry| std::cmp::Reverse(entry.0.len()));
291 Self { entries }
292 }
293
294 /// Return the canonical command prefix for a slice of command tokens.
295 ///
296 /// # Algorithm
297 ///
298 /// 1. Strip all flag tokens (tokens that start with `-`), except the
299 /// `-m` of `python -m`.
300 /// 2. Build candidates of depth 1..=3 from positional tokens (longest first).
301 /// 3. If a candidate matches a dictionary entry, return `arity` positional
302 /// tokens joined with spaces.
303 /// 4. If no dictionary entry matches, return the single base command name.
304 #[must_use]
305 pub fn classify(&self, tokens: &[&str]) -> String {
306 if tokens.is_empty() {
307 return String::new();
308 }
309
310 // Collect positional (non-flag) tokens, lowercased. `-m` directly
311 // after `python`/`python3` is kept: it names the module runner, and
312 // the table keys `python -m <module>` on it.
313 let positional: Vec<String> = tokens
314 .iter()
315 .enumerate()
316 .filter(|(index, token)| {
317 !token.starts_with('-')
318 || (*index == 1
319 && **token == "-m"
320 && matches!(
321 tokens[0].to_ascii_lowercase().as_str(),
322 "python" | "python3"
323 ))
324 })
325 .map(|(_, token)| token.to_ascii_lowercase())
326 .collect();
327
328 if positional.is_empty() {
329 return String::new();
330 }
331
332 // Try candidates from longest to shortest (max depth 3).
333 let max_depth = positional.len().min(3);
334 for depth in (1..=max_depth).rev() {
335 let candidate = positional[..depth].join(" ");
336 if let Some(&(_key, arity)) = self
337 .entries
338 .iter()
339 .find(|(key, _)| *key == candidate.as_str())
340 {
341 let take = (arity as usize).min(positional.len());
342 return positional[..take].join(" ");
343 }
344 }
345
346 // No match: return base command name only.
347 positional[0].clone()
348 }
349
350 /// Return `true` if the allow-rule `pattern` (a command prefix string such
351 /// as `"git status"`) matches the concrete command `command`.
352 ///
353 /// Matching is arity-aware:
354 /// - `"git status"` matches `"git status -s"` and `"git status --porcelain"`.
355 /// - `"git status"` does **not** match `"git push origin main"`.
356 /// - Exact string patterns (e.g. `"ls"`) still work as before.
357 ///
358 /// For patterns that are not in the arity table, the function falls back to
359 /// a plain prefix test on the normalised command so that existing exact-match
360 /// rules continue to work unchanged.
361 #[must_use]
362 pub fn allow_rule_matches(&self, pattern: &str, command: &str) -> bool {
363 let pattern_lower = pattern.trim().to_ascii_lowercase();
364 let command_tokens: Vec<&str> = command.split_whitespace().collect();
365
366 // Classify the concrete command through the arity dictionary.
367 let canonical = self.classify(&command_tokens);
368
369 // Primary check: the classified prefix equals the allow-rule pattern
370 // and is spelled literally at the front of the command, so a global
371 // option before the subcommand (`git -c k=v status`) is not covered.
372 if canonical == pattern_lower
373 && crate::command_safety::canonical_prefix_is_leading(&command_tokens, &canonical)
374 {
375 return true;
376 }
377
378 // Fallback: word-boundary prefix match for patterns not in the arity table.
379 // Matches the exact pattern or the pattern followed by a space (i.e., at
380 // word boundary), so "ls" matches "ls" and "ls -la" but NOT "lsof".
381 let command_lower = command.trim().to_ascii_lowercase();
382 // Normalise whitespace in both sides before comparing.
383 let pattern_norm: String = pattern_lower
384 .split_whitespace()
385 .collect::<Vec<_>>()
386 .join(" ");
387 let command_norm: String = command_lower
388 .split_whitespace()
389 .collect::<Vec<_>>()
390 .join(" ");
391 command_norm == pattern_norm
392 || (command_norm.starts_with(&pattern_norm)
393 && command_norm.as_bytes().get(pattern_norm.len()) == Some(&b' '))
394 }
395
396 /// Iterate over all entries in the dictionary.
397 pub fn entries(&self) -> impl Iterator<Item = (&str, u8)> {
398 self.entries.iter().map(|(k, v)| (*k, *v))
399 }
400
401 /// Return the number of entries in the dictionary.
402 #[must_use]
403 pub fn len(&self) -> usize {
404 self.entries.len()
405 }
406
407 /// Return `true` if the dictionary is empty.
408 #[must_use]
409 pub fn is_empty(&self) -> bool {
410 self.entries.is_empty()
411 }
412 }
413
414 impl Default for BashArityDict {
415 fn default() -> Self {
416 Self::new()
417 }
418 }
419
420 #[cfg(test)]
421 mod tests {
422 use super::*;
423
424 fn dict() -> BashArityDict {
425 BashArityDict::new()
426 }
427
428 // ── classify ─────────────────────────────────────────────────────────────
429
430 #[test]
431 fn classify_git_status_bare() {
432 assert_eq!(dict().classify(&["git", "status"]), "git status");
433 }
434
435 #[test]
436 fn classify_git_status_with_short_flag() {
437 assert_eq!(dict().classify(&["git", "status", "-s"]), "git status");
438 }
439
440 #[test]
441 fn classify_git_status_with_long_flag() {
442 assert_eq!(
443 dict().classify(&["git", "status", "--porcelain"]),
444 "git status"
445 );
446 }
447
448 #[test]
449 fn classify_git_push() {
450 assert_eq!(
451 dict().classify(&["git", "push", "origin", "main"]),
452 "git push"
453 );
454 }
455
456 #[test]
457 fn classify_git_push_force() {
458 assert_eq!(dict().classify(&["git", "push", "--force"]), "git push");
459 }
460
461 #[test]
462 fn classify_npm_run_dev_arity_3() {
463 assert_eq!(dict().classify(&["npm", "run", "dev"]), "npm run dev");
464 }
465
466 #[test]
467 fn classify_npm_install() {
468 assert_eq!(dict().classify(&["npm", "install"]), "npm install");
469 }
470
471 #[test]
472 fn classify_cargo_check_with_flag() {
473 assert_eq!(
474 dict().classify(&["cargo", "check", "--workspace"]),
475 "cargo check"
476 );
477 }
478
479 #[test]
480 fn classify_docker_compose_up_arity_3() {
481 assert_eq!(
482 dict().classify(&["docker", "compose", "up"]),
483 "docker compose up"
484 );
485 }
486
487 #[test]
488 fn classify_kubectl_get_pods_arity_3() {
489 assert_eq!(
490 dict().classify(&["kubectl", "get", "pods"]),
491 "kubectl get pods"
492 );
493 }
494
495 #[test]
496 fn classify_go_mod_tidy_arity_3() {
497 assert_eq!(dict().classify(&["go", "mod", "tidy"]), "go mod tidy");
498 }
499
500 #[test]
501 fn classify_make_no_subcommand() {
502 assert_eq!(dict().classify(&["make", "all"]), "make");
503 }
504
505 #[test]
506 fn classify_aws_s3_arity_3() {
507 assert_eq!(dict().classify(&["aws", "s3", "ls"]), "aws s3 ls");
508 }
509
510 #[test]
511 fn classify_terraform_plan() {
512 assert_eq!(
513 dict().classify(&["terraform", "plan", "-out=tfplan"]),
514 "terraform plan"
515 );
516 }
517
518 #[test]
519 fn classify_unknown_falls_back_to_base() {
520 assert_eq!(dict().classify(&["ls", "-la"]), "ls");
521 }
522
523 #[test]
524 fn classify_empty_returns_empty() {
525 assert_eq!(dict().classify(&[]), "");
526 }
527
528 // ── allow_rule_matches ────────────────────────────────────────────────────
529
530 #[test]
531 fn allow_rule_git_status_matches_with_flag() {
532 assert!(dict().allow_rule_matches("git status", "git status -s"));
533 }
534
535 #[test]
536 fn allow_rule_git_status_matches_porcelain() {
537 assert!(dict().allow_rule_matches("git status", "git status --porcelain"));
538 }
539
540 #[test]
541 fn allow_rule_git_status_does_not_match_push() {
542 assert!(!dict().allow_rule_matches("git status", "git push origin main"));
543 }
544
545 #[test]
546 fn allow_rule_git_status_does_not_match_checkout() {
547 assert!(!dict().allow_rule_matches("git status", "git checkout main"));
548 }
549
550 #[test]
551 fn allow_rule_npm_run_matches_dev() {
552 assert!(dict().allow_rule_matches("npm run dev", "npm run dev"));
553 }
554
555 #[test]
556 fn allow_rule_npm_run_dev_does_not_match_build() {
557 assert!(!dict().allow_rule_matches("npm run dev", "npm run build"));
558 }
559
560 #[test]
561 fn allow_rule_cargo_check_matches_with_flags() {
562 assert!(dict().allow_rule_matches("cargo check", "cargo check --workspace"));
563 }
564
565 #[test]
566 fn allow_rule_exact_match_still_works() {
567 // A pattern not in the arity table falls back to exact/prefix match.
568 assert!(dict().allow_rule_matches("ls", "ls -la"));
569 }
570
571 #[test]
572 fn allow_rule_make_matches_with_target() {
573 assert!(dict().allow_rule_matches("make", "make all"));
574 assert!(dict().allow_rule_matches("make", "make clean"));
575 }
576
577 #[test]
578 fn allow_rule_aws_s3_ls() {
579 assert!(dict().allow_rule_matches("aws s3 ls", "aws s3 ls"));
580 // "aws s3 cp" should not match "aws s3 ls"
581 assert!(!dict().allow_rule_matches("aws s3 ls", "aws s3 cp src dst"));
582 }
583
584 // ── coverage count ────────────────────────────────────────────────────────
585
586 #[test]
587 fn dict_covers_at_least_30_commands() {
588 // The issue requires 30+ common commands covered.
589 assert!(
590 BashArityDict::new().len() >= 30,
591 "expected at least 30 entries, got {}",
592 BashArityDict::new().len()
593 );
594 }
595 }
596
596 lines RUST