use std::sync::OnceLock;
use crate::discover::FlagCache;
use super::types::Rule;
static FLAG_CACHE: OnceLock<FlagCache> = OnceLock::new();
fn flag_cache() -> &'static FlagCache {
FLAG_CACHE.get_or_init(crate::discover::load_cache)
}
pub(super) fn matches_structured(rule: &Rule, input: &str) -> bool {
let mut tokens = input.split_whitespace();
let Some(cmd_name) = tokens.next() else {
return false;
};
let args: Vec<&str> = tokens.collect();
if let Some(expected) = &rule.command
&& cmd_name != expected.as_str()
{
return false;
}
let first_positional = args.iter().find(|a| !a.starts_with('-')).copied();
if let Some(expected) = &rule.subcommand
&& first_positional != Some(expected.as_str())
{
return false;
}
if let Some(list) = &rule.subcommands {
match first_positional {
Some(sub) if list.iter().any(|s| s == sub) => {}
_ => return false,
}
}
if let Some(required_flags) = &rule.flags {
let cache_key = rule.command.as_ref().map(|cmd| {
rule.subcommand
.as_ref()
.map_or_else(|| cmd.clone(), |sub| format!("{cmd} {sub}"))
});
let expanded =
crate::discover::expand_flags(required_flags, flag_cache(), cache_key.as_deref());
if !has_required_flag(&args, &expanded) {
return false;
}
}
if let Some(needle) = &rule.args_contain
&& !args.iter().any(|a| a.contains(needle.as_str()))
{
return false;
}
true
}
fn has_required_flag(args: &[&str], required_flags: &[String]) -> bool {
for arg in args {
if required_flags.iter().any(|f| f == arg) {
return true;
}
if arg.starts_with('-')
&& !arg.starts_with("--")
&& arg.len() > 2
&& arg.as_bytes().iter().skip(1).all(u8::is_ascii_alphabetic)
{
for ch in arg.chars().skip(1) {
let short = format!("-{ch}");
if required_flags.iter().any(|f| f == &short) {
return true;
}
}
}
}
false
}
pub(super) fn format_rule_reason(rule: &Rule, label: &str) -> String {
if rule.has_structured_fields() {
format!("{label}: {}", rule.structured_description())
} else {
format!("{label}: {}", rule.pattern.as_str())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::config::RuleTarget;
use crate::pattern::Pattern;
use crate::verdict::Decision;
fn structured_rule(
decision: Decision,
command: Option<&str>,
subcommand: Option<&str>,
flags: Option<Vec<&str>>,
) -> Rule {
let mut r = Rule::new(RuleTarget::Command, decision, "*");
r.pattern = Pattern::any();
r.command = command.map(String::from);
r.subcommand = subcommand.map(String::from);
r.flags = flags.map(|f| f.into_iter().map(String::from).collect());
r
}
#[test]
fn structured_command_matches() {
let rule = structured_rule(Decision::Deny, Some("git"), None, None);
assert!(matches_structured(&rule, "git push origin main"));
assert!(matches_structured(&rule, "git status"));
assert!(!matches_structured(&rule, "docker ps"));
}
#[test]
fn structured_subcommand_matches() {
let rule = structured_rule(Decision::Deny, Some("git"), Some("push"), None);
assert!(matches_structured(&rule, "git push origin main"));
assert!(!matches_structured(&rule, "git status"));
assert!(matches_structured(&rule, "git --no-pager push"));
}
#[test]
fn structured_flags_matches() {
let rule = structured_rule(
Decision::Deny,
Some("git"),
Some("push"),
Some(vec!["--force", "-f"]),
);
assert!(matches_structured(&rule, "git push --force origin main"));
assert!(matches_structured(&rule, "git push origin main --force"));
assert!(matches_structured(&rule, "git push -f origin main"));
assert!(!matches_structured(&rule, "git push origin main"));
}
#[test]
fn structured_combined_short_flags() {
let rule = structured_rule(
Decision::Deny,
Some("curl"),
None,
Some(vec!["-k", "--insecure"]),
);
let flags = rule.flags.as_ref().unwrap();
assert!(has_required_flag(&["-kv", "http://example.com"], flags));
assert!(has_required_flag(
&["--insecure", "http://example.com"],
flags
));
assert!(!has_required_flag(&["-v", "http://example.com"], flags));
}
#[test]
fn structured_subcommands_list() {
let mut rule = structured_rule(Decision::Allow, Some("git"), None, None);
rule.subcommands = Some(vec!["status".into(), "log".into(), "diff".into()]);
assert!(matches_structured(&rule, "git status"));
assert!(matches_structured(&rule, "git log --oneline"));
assert!(!matches_structured(&rule, "git push origin"));
}
#[test]
fn structured_args_contain() {
let mut rule = structured_rule(Decision::Deny, Some("curl"), None, None);
rule.args_contain = Some("password".into());
assert!(matches_structured(
&rule,
"curl http://example.com?password=123"
));
assert!(!matches_structured(&rule, "curl http://example.com"));
}
#[test]
fn structured_empty_input_no_match() {
let rule = structured_rule(Decision::Deny, Some("git"), None, None);
assert!(!matches_structured(&rule, ""));
}
#[test]
fn has_structured_fields_detects_fields() {
let plain = Rule::new(RuleTarget::Command, Decision::Allow, "git *");
assert!(!plain.has_structured_fields());
let structured = structured_rule(Decision::Deny, Some("git"), None, None);
assert!(structured.has_structured_fields());
}
}