Skip to main content

stynx_code_permission/application/
pattern_matcher.rs

1use 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}