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