#[derive(Debug, Clone, Default)]
pub struct PermissionConfig {
pub allow: Vec<String>,
pub deny: Vec<String>,
}
impl PermissionConfig {
pub fn check(&self, command: &str) -> Option<bool> {
for pattern in &self.deny {
if glob_match(pattern, command) {
return Some(false);
}
}
for pattern in &self.allow {
if glob_match(pattern, command) {
return Some(true);
}
}
None
}
pub fn is_empty(&self) -> bool {
self.allow.is_empty() && self.deny.is_empty()
}
}
#[derive(Debug, Clone, Default)]
pub struct DirectoryRestrictions {
pub allow: Vec<String>,
pub deny: Vec<String>,
}
impl DirectoryRestrictions {
pub fn is_empty(&self) -> bool {
self.allow.is_empty() && self.deny.is_empty()
}
pub fn check_path(&self, path: &str) -> Result<(), String> {
if self.is_empty() {
return Ok(());
}
let resolved = resolve_path(path);
for denied in &self.deny {
let denied_resolved = resolve_path(denied);
if path_is_under(&resolved, &denied_resolved) {
return Err(format!(
"Access denied: '{}' is under restricted directory '{}'",
path, denied
));
}
}
if !self.allow.is_empty() {
let allowed = self.allow.iter().any(|a| {
let a_resolved = resolve_path(a);
path_is_under(&resolved, &a_resolved)
});
if !allowed {
return Err(format!(
"Access denied: '{}' is not under any allowed directory",
path
));
}
}
Ok(())
}
}
fn resolve_path(path: &str) -> String {
if let Ok(canonical) = std::fs::canonicalize(path) {
return canonical.to_string_lossy().to_string();
}
let p = std::path::Path::new(path);
let absolute = if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("/"))
.join(p)
};
let mut components = Vec::new();
for component in absolute.components() {
match component {
std::path::Component::ParentDir => {
components.pop();
}
std::path::Component::CurDir => {}
other => components.push(other),
}
}
let normalized: std::path::PathBuf = components.iter().collect();
normalized.to_string_lossy().to_string()
}
fn path_is_under(path: &str, dir: &str) -> bool {
let dir_with_sep = if dir.ends_with('/') {
dir.to_string()
} else {
format!("{}/", dir)
};
path == dir || path.starts_with(&dir_with_sep)
}
pub fn glob_match(pattern: &str, text: &str) -> bool {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == text;
}
let mut pos = 0;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
if !text.starts_with(part) {
return false;
}
pos = part.len();
} else if i == parts.len() - 1 {
if !text[pos..].ends_with(part) {
return false;
}
pos = text.len();
} else {
match text[pos..].find(part) {
Some(idx) => pos += idx + part.len(),
None => return false,
}
}
}
true
}
pub fn parse_toml_array(value: &str) -> Vec<String> {
let trimmed = value.trim();
if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
return Vec::new();
}
let inner = &trimmed[1..trimmed.len() - 1];
inner
.split(',')
.map(|s| {
let s = s.trim();
if (s.starts_with('"') && s.ends_with('"'))
|| (s.starts_with('\'') && s.ends_with('\''))
{
s[1..s.len() - 1].to_string()
} else {
s.to_string()
}
})
.filter(|s| !s.is_empty())
.collect()
}
pub fn parse_permissions_from_config(content: &str) -> PermissionConfig {
let mut config = PermissionConfig::default();
let mut in_permissions = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
in_permissions = trimmed == "[permissions]";
continue;
}
if !in_permissions {
continue;
}
if let Some((key, value)) = trimmed.split_once('=') {
let key = key.trim();
let value = value.trim();
match key {
"allow" => config.allow = parse_toml_array(value),
"deny" => config.deny = parse_toml_array(value),
_ => {}
}
}
}
config
}
pub fn parse_directories_from_config(content: &str) -> DirectoryRestrictions {
let mut config = DirectoryRestrictions::default();
let mut in_directories = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
in_directories = trimmed == "[directories]";
continue;
}
if !in_directories {
continue;
}
if let Some((key, value)) = trimmed.split_once('=') {
let key = key.trim();
let value = value.trim();
match key {
"allow" => config.allow = parse_toml_array(value),
"deny" => config.deny = parse_toml_array(value),
_ => {}
}
}
}
config
}
pub fn parse_mcp_servers_from_config(content: &str) -> Vec<McpServerConfig> {
let mut servers: Vec<McpServerConfig> = Vec::new();
let mut current_name: Option<String> = None;
let mut current_command: Option<String> = None;
let mut current_args: Vec<String> = Vec::new();
let mut current_env: Vec<(String, String)> = Vec::new();
let flush = |name: &mut Option<String>,
command: &mut Option<String>,
args: &mut Vec<String>,
env: &mut Vec<(String, String)>,
servers: &mut Vec<McpServerConfig>| {
if let (Some(n), Some(c)) = (name.take(), command.take()) {
servers.push(McpServerConfig {
name: n,
command: c,
args: std::mem::take(args),
env: std::mem::take(env),
});
} else {
*name = None;
*command = None;
args.clear();
env.clear();
}
};
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
flush(
&mut current_name,
&mut current_command,
&mut current_args,
&mut current_env,
&mut servers,
);
let section = &trimmed[1..trimmed.len() - 1];
if let Some(name) = section.strip_prefix("mcp_servers.") {
let name = name.trim();
if !name.is_empty() {
current_name = Some(name.to_string());
}
}
continue;
}
if current_name.is_none() {
continue;
}
if let Some((key, value)) = trimmed.split_once('=') {
let key = key.trim();
let value = value.trim();
match key {
"command" => {
let v = strip_quotes(value);
if !v.is_empty() {
current_command = Some(v);
}
}
"args" => {
current_args = parse_toml_array(value);
}
"env" => {
current_env = parse_inline_table(value);
}
_ => {}
}
}
}
flush(
&mut current_name,
&mut current_command,
&mut current_args,
&mut current_env,
&mut servers,
);
servers
}
fn strip_quotes(s: &str) -> String {
let s = s.trim();
if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
if s.len() >= 2 {
s[1..s.len() - 1].to_string()
} else {
String::new()
}
} else {
s.to_string()
}
}
fn parse_inline_table(s: &str) -> Vec<(String, String)> {
let s = s.trim();
let inner = if s.starts_with('{') && s.ends_with('}') {
&s[1..s.len() - 1]
} else {
return Vec::new();
};
let mut result = Vec::new();
for pair in inner.split(',') {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
if let Some((k, v)) = pair.split_once('=') {
let k = k.trim().to_string();
let v = strip_quotes(v);
if !k.is_empty() {
result.push((k, v));
}
}
}
result
}
#[derive(Debug, Clone)]
pub struct McpServerConfig {
pub name: String,
pub command: String,
pub args: Vec<String>,
pub env: Vec<(String, String)>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_module_glob_match() {
assert!(glob_match("cargo *", "cargo test"));
assert!(!glob_match("cargo *", "rustc build"));
assert!(glob_match("*", "anything"));
assert!(glob_match("exact", "exact"));
assert!(!glob_match("exact", "other"));
}
#[test]
fn test_config_module_permission_check() {
let perms = PermissionConfig {
allow: vec!["cargo *".to_string()],
deny: vec!["rm *".to_string()],
};
assert_eq!(perms.check("cargo test"), Some(true));
assert_eq!(perms.check("rm -rf /"), Some(false));
assert_eq!(perms.check("python script.py"), None);
}
#[test]
fn test_config_module_parse_toml_array() {
let result = parse_toml_array(r#"["one", "two", "three"]"#);
assert_eq!(result, vec!["one", "two", "three"]);
}
#[test]
fn test_config_module_parse_permissions() {
let content = r#"
[permissions]
allow = ["cargo *", "git *"]
deny = ["rm *"]
"#;
let config = parse_permissions_from_config(content);
assert_eq!(config.allow, vec!["cargo *", "git *"]);
assert_eq!(config.deny, vec!["rm *"]);
}
#[test]
fn test_config_module_parse_directories() {
let content = r#"
[directories]
allow = ["/home/user/project"]
deny = ["/etc"]
"#;
let config = parse_directories_from_config(content);
assert_eq!(config.allow, vec!["/home/user/project"]);
assert_eq!(config.deny, vec!["/etc"]);
}
#[test]
fn test_config_module_parse_mcp_servers() {
let content = r#"
[mcp_servers.test]
command = "npx"
args = ["-y", "test-server"]
env = { API_KEY = "secret" }
"#;
let servers = parse_mcp_servers_from_config(content);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "test");
assert_eq!(servers[0].command, "npx");
assert_eq!(servers[0].args, vec!["-y", "test-server"]);
assert_eq!(
servers[0].env,
vec![("API_KEY".to_string(), "secret".to_string())]
);
}
#[test]
fn test_config_module_strip_quotes() {
assert_eq!(strip_quotes("\"hello\""), "hello");
assert_eq!(strip_quotes("'hello'"), "hello");
assert_eq!(strip_quotes("hello"), "hello");
assert_eq!(strip_quotes("\"\""), "");
assert_eq!(strip_quotes(""), "");
}
#[test]
fn test_config_module_parse_inline_table() {
let result = parse_inline_table(r#"{ KEY = "value", OTHER = "val2" }"#);
assert_eq!(result.len(), 2);
assert_eq!(result[0], ("KEY".to_string(), "value".to_string()));
assert_eq!(result[1], ("OTHER".to_string(), "val2".to_string()));
}
#[test]
fn test_config_module_parse_inline_table_empty() {
let result = parse_inline_table("{}");
assert!(result.is_empty());
let result = parse_inline_table("not a table");
assert!(result.is_empty());
}
#[test]
fn test_config_module_resolve_path_normalizes_parent_dir() {
let resolved = resolve_path("/tmp/a/../b");
assert_eq!(resolved, "/tmp/b");
}
#[test]
fn test_config_module_resolve_path_absolute() {
let resolved = resolve_path("/usr/bin/env");
assert!(resolved.starts_with('/'));
assert!(resolved.contains("usr"));
}
#[test]
fn test_config_module_path_is_under_basic() {
assert!(path_is_under("/etc/passwd", "/etc"));
assert!(path_is_under("/etc", "/etc"));
assert!(!path_is_under("/etcetc", "/etc"));
assert!(!path_is_under("/tmp/file", "/etc"));
}
}