use crate::parser::{self, SimpleCommand, Statement};
use super::config::{FlagsMatcher, Matcher, PipelineMatcher, RedirectMatcher, StringOrList};
pub fn normalize_command_name(name: &str) -> &str {
name.rsplit('/').next().unwrap_or(name)
}
fn arg_matches_flag(arg: &str, flag: &str) -> bool {
if arg == flag {
return true;
}
if flag.starts_with("--") {
let with_value_prefix = format!("{flag}=");
return arg.starts_with(&with_value_prefix);
}
if flag.starts_with('-') && !flag.starts_with("--") && flag.len() == 2 {
let Some(needle) = flag.chars().nth(1) else {
return false;
};
if arg.starts_with('-') && !arg.starts_with("--") && arg.len() > 2 {
return arg[1..].chars().any(|c| c == needle);
}
}
false
}
fn flags_match(flags_matcher: &FlagsMatcher, argv: &[String]) -> bool {
if !flags_matcher.any_of.is_empty() {
let has_any = flags_matcher
.any_of
.iter()
.any(|f| argv.iter().any(|a| arg_matches_flag(a, f)));
if !has_any {
return false;
}
}
if !flags_matcher.all_of.is_empty() {
let has_all = flags_matcher
.all_of
.iter()
.all(|f| argv.iter().any(|a| arg_matches_flag(a, f)));
if !has_all {
return false;
}
}
if !flags_matcher.none_of.is_empty() {
let has_any_excluded = flags_matcher
.none_of
.iter()
.any(|f| argv.iter().any(|a| arg_matches_flag(a, f)));
if has_any_excluded {
return false;
}
}
if !flags_matcher.starts_with.is_empty() {
let has_prefix = flags_matcher
.starts_with
.iter()
.any(|prefix| argv.iter().any(|a| a.starts_with(prefix)));
if !has_prefix {
return false;
}
}
true
}
pub fn matches_rule(matcher: &Matcher, cmd: &SimpleCommand) -> bool {
match matcher {
Matcher::Command {
command,
flags,
args,
} => {
let cmd_name = match &cmd.name {
Some(n) => n.as_str(),
None => return false,
};
if !command.matches(normalize_command_name(cmd_name)) {
return false;
}
if let Some(ref flags_matcher) = flags {
if !flags_match(flags_matcher, &cmd.argv) {
return false;
}
}
if let Some(args_matcher) = args {
if !args_matcher.any_of.is_empty() {
let has_any = args_matcher
.any_of
.iter()
.any(|pattern| cmd.argv.iter().any(|a| glob_match::glob_match(pattern, a)));
if !has_any {
return false;
}
}
}
true
}
Matcher::Redirect { redirect } => matches_redirect(redirect, cmd),
Matcher::Pipeline { .. } => {
false
}
}
}
pub fn matches_pipeline(matcher: &PipelineMatcher, pipe: &parser::Pipeline) -> bool {
if matcher.stages.is_empty() {
return false;
}
let mut matcher_idx = 0;
for stage in &pipe.stages {
if matcher_idx >= matcher.stages.len() {
break;
}
if let Statement::SimpleCommand(cmd) = stage {
if let Some(ref name) = cmd.name {
let basename = normalize_command_name(name);
if matcher.stages[matcher_idx].command.matches(basename)
&& matcher.stages[matcher_idx]
.flags
.as_ref()
.is_none_or(|f| flags_match(f, &cmd.argv))
{
matcher_idx += 1;
} else if let Some(inner) = crate::parser::wrappers::unwrap_transparent(cmd) {
if let Some(ref inner_name) = inner.name {
let inner_basename = normalize_command_name(inner_name);
if matcher.stages[matcher_idx].command.matches(inner_basename)
&& matcher.stages[matcher_idx]
.flags
.as_ref()
.is_none_or(|f| flags_match(f, &inner.argv))
{
matcher_idx += 1;
}
}
}
}
}
}
matcher_idx == matcher.stages.len()
}
pub fn matches_redirect(redirect_matcher: &RedirectMatcher, cmd: &SimpleCommand) -> bool {
cmd.redirects.iter().any(|redir| {
let op_matches = match &redirect_matcher.op {
Some(op_matcher) => op_matcher.matches(&redir.op.to_string()),
None => true,
};
let target_matches = match &redirect_matcher.target {
Some(target_matcher) => match target_matcher {
StringOrList::Single(pattern) => glob_match::glob_match(pattern, &redir.target),
StringOrList::List { any_of } => any_of
.iter()
.any(|p| glob_match::glob_match(p, &redir.target)),
},
None => true,
};
op_matches && target_matches
})
}
#[cfg(test)]
mod tests {
use super::arg_matches_flag;
use super::matches_pipeline;
use crate::policy::config::{FlagsMatcher, PipelineMatcher, StageMatcher, StringOrList};
fn make_pipeline(commands: &[&str]) -> crate::parser::Pipeline {
crate::parser::Pipeline {
stages: commands
.iter()
.map(|c| {
let parsed = crate::parser::parse(c).unwrap();
match parsed {
crate::parser::Statement::Pipeline(p) => {
p.stages.into_iter().next().unwrap()
}
other => other,
}
})
.collect(),
negated: false,
}
}
#[test]
fn test_arg_matches_flag_exact_match() {
assert!(arg_matches_flag("-f", "-f"));
assert!(arg_matches_flag("--force", "--force"));
assert!(!arg_matches_flag("--forceful", "--force"));
}
#[test]
fn test_arg_matches_flag_long_with_equals() {
assert!(arg_matches_flag("--output=out.txt", "--output"));
assert!(arg_matches_flag("--prune=now", "--prune"));
assert!(!arg_matches_flag("--output", "--output-file"));
}
#[test]
fn test_arg_matches_flag_combined_short() {
assert!(arg_matches_flag("-xffd", "-f"));
assert!(arg_matches_flag("-ffd", "-f"));
assert!(arg_matches_flag("-fd", "-f"));
assert!(arg_matches_flag("-fd", "-d"));
assert!(!arg_matches_flag("-n", "-f"));
}
#[test]
fn test_pipeline_stage_none_of_excludes_when_flag_present() {
let matcher = PipelineMatcher {
stages: vec![
StageMatcher {
command: StringOrList::List {
any_of: vec!["curl".into(), "wget".into()],
},
flags: None,
},
StageMatcher {
command: StringOrList::List {
any_of: vec!["python".into(), "python3".into()],
},
flags: Some(FlagsMatcher {
none_of: vec!["-m".into(), "-c".into()],
any_of: vec![],
all_of: vec![],
starts_with: vec![],
}),
},
],
};
let pipe = make_pipeline(&["curl http://example.com", "python3 -m json.tool"]);
assert!(
!matches_pipeline(&matcher, &pipe),
"Should NOT match: python3 has -m flag which is in none_of"
);
}
#[test]
fn test_pipeline_stage_none_of_matches_when_flag_absent() {
let matcher = PipelineMatcher {
stages: vec![
StageMatcher {
command: StringOrList::List {
any_of: vec!["curl".into(), "wget".into()],
},
flags: None,
},
StageMatcher {
command: StringOrList::List {
any_of: vec!["python".into(), "python3".into()],
},
flags: Some(FlagsMatcher {
none_of: vec!["-m".into(), "-c".into()],
any_of: vec![],
all_of: vec![],
starts_with: vec![],
}),
},
],
};
let pipe = make_pipeline(&["curl http://example.com", "python3"]);
assert!(
matches_pipeline(&matcher, &pipe),
"Should match: bare python3 has no excluded flags"
);
}
#[test]
fn test_pipeline_stage_any_of_matches_when_flag_present() {
let matcher = PipelineMatcher {
stages: vec![
StageMatcher {
command: StringOrList::List {
any_of: vec!["curl".into(), "wget".into()],
},
flags: None,
},
StageMatcher {
command: StringOrList::List {
any_of: vec!["python".into(), "python3".into()],
},
flags: Some(FlagsMatcher {
any_of: vec!["-c".into(), "-e".into()],
none_of: vec![],
all_of: vec![],
starts_with: vec![],
}),
},
],
};
let pipe = make_pipeline(&["curl http://example.com", "python3 -c 'print(1)'"]);
assert!(
matches_pipeline(&matcher, &pipe),
"Should match: python3 has -c flag"
);
}
#[test]
fn test_pipeline_stage_any_of_no_match_when_flag_absent() {
let matcher = PipelineMatcher {
stages: vec![
StageMatcher {
command: StringOrList::List {
any_of: vec!["curl".into(), "wget".into()],
},
flags: None,
},
StageMatcher {
command: StringOrList::List {
any_of: vec!["python".into(), "python3".into()],
},
flags: Some(FlagsMatcher {
any_of: vec!["-c".into(), "-e".into()],
none_of: vec![],
all_of: vec![],
starts_with: vec![],
}),
},
],
};
let pipe = make_pipeline(&["curl http://example.com", "python3 -m json.tool"]);
assert!(
!matches_pipeline(&matcher, &pipe),
"Should NOT match: python3 has -m not -c/-e"
);
}
#[test]
fn test_pipeline_stage_flags_on_first_stage() {
let matcher = PipelineMatcher {
stages: vec![
StageMatcher {
command: StringOrList::Single("curl".into()),
flags: Some(FlagsMatcher {
any_of: vec!["-s".into()],
none_of: vec![],
all_of: vec![],
starts_with: vec![],
}),
},
StageMatcher {
command: StringOrList::Single("python3".into()),
flags: None,
},
],
};
let pipe = make_pipeline(&["curl -s http://example.com", "python3"]);
assert!(matches_pipeline(&matcher, &pipe));
let pipe_no_s = make_pipeline(&["curl http://example.com", "python3"]);
assert!(!matches_pipeline(&matcher, &pipe_no_s));
}
#[test]
fn test_pipeline_no_flags_backward_compatible() {
let matcher = PipelineMatcher {
stages: vec![
StageMatcher {
command: StringOrList::List {
any_of: vec!["curl".into(), "wget".into()],
},
flags: None,
},
StageMatcher {
command: StringOrList::List {
any_of: vec!["sh".into(), "bash".into()],
},
flags: None,
},
],
};
let pipe = make_pipeline(&["curl http://example.com", "bash"]);
assert!(matches_pipeline(&matcher, &pipe));
}
fn fm(
any_of: &[&str],
all_of: &[&str],
none_of: &[&str],
starts_with: &[&str],
) -> FlagsMatcher {
FlagsMatcher {
any_of: any_of.iter().map(|s| s.to_string()).collect(),
all_of: all_of.iter().map(|s| s.to_string()).collect(),
none_of: none_of.iter().map(|s| s.to_string()).collect(),
starts_with: starts_with.iter().map(|s| s.to_string()).collect(),
}
}
fn argv(args: &[&str]) -> Vec<String> {
args.iter().map(|s| s.to_string()).collect()
}
#[test]
fn test_flags_match_empty_matcher() {
assert!(super::flags_match(
&fm(&[], &[], &[], &[]),
&argv(&["--anything"])
));
assert!(super::flags_match(&fm(&[], &[], &[], &[]), &argv(&[])));
}
#[test]
fn test_flags_match_any_of_present() {
let m = fm(&["-f", "-v"], &[], &[], &[]);
assert!(super::flags_match(&m, &argv(&["cmd", "-f"])));
assert!(super::flags_match(&m, &argv(&["cmd", "-v"])));
assert!(super::flags_match(&m, &argv(&["cmd", "-f", "-v"])));
}
#[test]
fn test_flags_match_any_of_absent() {
let m = fm(&["-f", "-v"], &[], &[], &[]);
assert!(!super::flags_match(&m, &argv(&["cmd", "-x"])));
assert!(!super::flags_match(&m, &argv(&["cmd"])));
}
#[test]
fn test_flags_match_all_of_present() {
let m = fm(&[], &["-f", "-v"], &[], &[]);
assert!(super::flags_match(&m, &argv(&["cmd", "-f", "-v"])));
assert!(super::flags_match(&m, &argv(&["cmd", "-v", "-f", "-x"])));
}
#[test]
fn test_flags_match_all_of_partial() {
let m = fm(&[], &["-f", "-v"], &[], &[]);
assert!(!super::flags_match(&m, &argv(&["cmd", "-f"])));
assert!(!super::flags_match(&m, &argv(&["cmd", "-v"])));
}
#[test]
fn test_flags_match_all_of_absent() {
let m = fm(&[], &["-f", "-v"], &[], &[]);
assert!(!super::flags_match(&m, &argv(&["cmd", "-x"])));
}
#[test]
fn test_flags_match_none_of_absent() {
let m = fm(&[], &[], &["-f", "-v"], &[]);
assert!(super::flags_match(&m, &argv(&["cmd", "-x"])));
assert!(super::flags_match(&m, &argv(&["cmd"])));
}
#[test]
fn test_flags_match_none_of_present() {
let m = fm(&[], &[], &["-f", "-v"], &[]);
assert!(!super::flags_match(&m, &argv(&["cmd", "-f"])));
assert!(!super::flags_match(&m, &argv(&["cmd", "-v"])));
assert!(!super::flags_match(&m, &argv(&["cmd", "-f", "-v"])));
}
#[test]
fn test_flags_match_starts_with_present() {
let m = fm(&[], &[], &[], &["-x"]);
assert!(super::flags_match(&m, &argv(&["cmd", "-xvf"])));
assert!(super::flags_match(&m, &argv(&["cmd", "-x"])));
}
#[test]
fn test_flags_match_starts_with_absent() {
let m = fm(&[], &[], &[], &["-x"]);
assert!(!super::flags_match(&m, &argv(&["cmd", "-v"])));
assert!(!super::flags_match(&m, &argv(&["cmd"])));
}
#[test]
fn test_flags_match_combined_constraints() {
let m = fm(&["-c", "-e"], &[], &["--dry-run"], &[]);
assert!(super::flags_match(&m, &argv(&["cmd", "-c", "arg"])));
assert!(!super::flags_match(&m, &argv(&["cmd", "-c", "--dry-run"])));
assert!(!super::flags_match(&m, &argv(&["cmd", "--dry-run"])));
assert!(!super::flags_match(&m, &argv(&["cmd", "-x"])));
}
}