stynx_code_permission/application/
pattern_matcher.rs1use serde_json::Value;
2
3#[derive(Debug, Clone)]
5pub struct PermissionRule {
6 pub tool: String,
7 pub pattern: Option<String>,
8}
9
10pub fn parse_rule(rule: &str) -> PermissionRule {
12 let rule = rule.trim();
13 if let Some(paren_start) = rule.find('(')
14 && rule.ends_with(')') {
15 let tool = rule[..paren_start].to_string();
16 let pattern = rule[paren_start + 1..rule.len() - 1].to_string();
17 return PermissionRule {
18 tool,
19 pattern: Some(pattern),
20 };
21 }
22 PermissionRule {
23 tool: rule.to_string(),
24 pattern: None,
25 }
26}
27
28pub fn rule_matches(rule: &PermissionRule, tool_name: &str, input: &Value) -> bool {
30 if rule.tool != tool_name {
31 return false;
32 }
33
34 let Some(pattern) = &rule.pattern else {
35 return true;
37 };
38
39 let field_value = match tool_name {
41 "bash" => input.get("command").and_then(|v| v.as_str()),
42 "file_write" | "file_edit" => input.get("file_path").and_then(|v| v.as_str()),
43 "read" => input.get("file_path").and_then(|v| v.as_str()),
44 "web_fetch" => input.get("url").and_then(|v| v.as_str()),
45 _ => {
46 None
48 }
49 };
50
51 let Some(value) = field_value else {
52 return false;
53 };
54
55 glob_match(pattern, value)
56}
57
58fn glob_match(pattern: &str, text: &str) -> bool {
60 let parts: Vec<&str> = pattern.split('*').collect();
61
62 if parts.len() == 1 {
63 return pattern == text;
65 }
66
67 let mut pos = 0;
68
69 if !parts[0].is_empty() {
71 if !text.starts_with(parts[0]) {
72 return false;
73 }
74 pos = parts[0].len();
75 }
76
77 for part in &parts[1..parts.len() - 1] {
79 if part.is_empty() {
80 continue;
81 }
82 match text[pos..].find(part) {
83 Some(idx) => pos += idx + part.len(),
84 None => return false,
85 }
86 }
87
88 let last = parts[parts.len() - 1];
90 if !last.is_empty() {
91 text[pos..].ends_with(last)
92 } else {
93 true
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100 use serde_json::json;
101
102 #[test]
103 fn test_parse_rule_simple() {
104 let rule = parse_rule("read");
105 assert_eq!(rule.tool, "read");
106 assert!(rule.pattern.is_none());
107 }
108
109 #[test]
110 fn test_parse_rule_with_pattern() {
111 let rule = parse_rule("bash(git *)");
112 assert_eq!(rule.tool, "bash");
113 assert_eq!(rule.pattern.as_deref(), Some("git *"));
114 }
115
116 #[test]
117 fn test_rule_matches_tool_only() {
118 let rule = parse_rule("read");
119 assert!(rule_matches(&rule, "read", &json!({"file_path": "/any/path"})));
120 assert!(!rule_matches(&rule, "bash", &json!({"command": "ls"})));
121 }
122
123 #[test]
124 fn test_rule_matches_bash_pattern() {
125 let rule = parse_rule("bash(git *)");
126 assert!(rule_matches(&rule, "bash", &json!({"command": "git status"})));
127 assert!(rule_matches(&rule, "bash", &json!({"command": "git diff --cached"})));
128 assert!(!rule_matches(&rule, "bash", &json!({"command": "rm -rf /"})));
129 }
130
131 #[test]
132 fn test_glob_match() {
133 assert!(glob_match("git *", "git status"));
134 assert!(glob_match("git *", "git diff --cached"));
135 assert!(!glob_match("git *", "rm -rf"));
136 assert!(glob_match("*", "anything"));
137 assert!(glob_match("*.rs", "main.rs"));
138 assert!(!glob_match("*.rs", "main.py"));
139 }
140}