use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub download: Option<String>,
#[serde(default)]
pub permissions: PluginPermissions,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PluginPermissions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read_files: Option<PermissionRule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edit_files: Option<PermissionRule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub create_files: Option<PermissionRule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delete_files: Option<PermissionRule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub move_files: Option<PermissionRule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub http_requests: Option<PermissionRule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execute_commands: Option<PermissionRule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_storage: Option<PermissionRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionRule {
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PermissionType {
ReadFiles,
EditFiles,
CreateFiles,
DeleteFiles,
MoveFiles,
HttpRequests,
ExecuteCommands,
PluginStorage,
}
impl PermissionType {
pub fn label(&self) -> &'static str {
match self {
Self::ReadFiles => "read files",
Self::EditFiles => "edit files",
Self::CreateFiles => "create files",
Self::DeleteFiles => "delete files",
Self::MoveFiles => "move files",
Self::HttpRequests => "make HTTP requests",
Self::ExecuteCommands => "execute commands",
Self::PluginStorage => "use plugin storage",
}
}
pub fn key(&self) -> &'static str {
match self {
Self::ReadFiles => "read_files",
Self::EditFiles => "edit_files",
Self::CreateFiles => "create_files",
Self::DeleteFiles => "delete_files",
Self::MoveFiles => "move_files",
Self::HttpRequests => "http_requests",
Self::ExecuteCommands => "execute_commands",
Self::PluginStorage => "plugin_storage",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PermissionCheck {
Allowed,
Denied,
NotConfigured,
}
fn extract_path_from_scope(scope: &str) -> Option<&str> {
if let Some(start) = scope.find("](") {
let after = &scope[start + 2..];
if let Some(end) = after.find(')') {
let path = &after[..end];
return Some(path.strip_prefix('/').unwrap_or(path));
}
}
let path = scope.strip_prefix('/').unwrap_or(scope);
if path.is_empty() { None } else { Some(path) }
}
pub fn check_file_permission(rule: &PermissionRule, file_path: &str) -> PermissionCheck {
let file_path = file_path.strip_prefix('/').unwrap_or(file_path);
for scope in &rule.exclude {
let scope_trimmed = scope.trim();
if scope_trimmed.eq_ignore_ascii_case("all") {
return PermissionCheck::Denied;
}
if let Some(path) = extract_path_from_scope(scope_trimmed)
&& path_matches(file_path, path)
{
return PermissionCheck::Denied;
}
}
for scope in &rule.include {
let scope_trimmed = scope.trim();
if scope_trimmed.eq_ignore_ascii_case("all") {
return PermissionCheck::Allowed;
}
if let Some(path) = extract_path_from_scope(scope_trimmed)
&& path_matches(file_path, path)
{
return PermissionCheck::Allowed;
}
}
PermissionCheck::NotConfigured
}
fn path_matches(file_path: &str, pattern_path: &str) -> bool {
let file = Path::new(file_path);
let pattern = Path::new(pattern_path);
if file == pattern {
return true;
}
if file.starts_with(pattern) {
return true;
}
if let Some(parent) = pattern.parent()
&& !parent.as_os_str().is_empty()
&& file.starts_with(parent)
{
return true;
}
false
}
pub fn check_http_permission(rule: &PermissionRule, url: &str) -> PermissionCheck {
let domain = extract_domain(url);
for scope in &rule.exclude {
let scope_trimmed = scope.trim();
if scope_trimmed.eq_ignore_ascii_case("all") {
return PermissionCheck::Denied;
}
if domain_matches(&domain, scope_trimmed) {
return PermissionCheck::Denied;
}
}
for scope in &rule.include {
let scope_trimmed = scope.trim();
if scope_trimmed.eq_ignore_ascii_case("all") {
return PermissionCheck::Allowed;
}
if domain_matches(&domain, scope_trimmed) {
return PermissionCheck::Allowed;
}
}
PermissionCheck::NotConfigured
}
pub fn check_storage_permission(rule: &PermissionRule) -> PermissionCheck {
for scope in &rule.exclude {
if scope.trim().eq_ignore_ascii_case("all") {
return PermissionCheck::Denied;
}
}
for scope in &rule.include {
if scope.trim().eq_ignore_ascii_case("all") {
return PermissionCheck::Allowed;
}
}
PermissionCheck::NotConfigured
}
pub fn get_permission_rule(
permissions: &PluginPermissions,
permission_type: PermissionType,
) -> Option<&PermissionRule> {
match permission_type {
PermissionType::ReadFiles => permissions.read_files.as_ref(),
PermissionType::EditFiles => permissions.edit_files.as_ref(),
PermissionType::CreateFiles => permissions.create_files.as_ref(),
PermissionType::DeleteFiles => permissions.delete_files.as_ref(),
PermissionType::MoveFiles => permissions.move_files.as_ref(),
PermissionType::HttpRequests => permissions.http_requests.as_ref(),
PermissionType::ExecuteCommands => permissions.execute_commands.as_ref(),
PermissionType::PluginStorage => permissions.plugin_storage.as_ref(),
}
}
pub fn check_permission(
plugins_config: &HashMap<String, PluginConfig>,
plugin_id: &str,
permission_type: PermissionType,
target: &str,
) -> PermissionCheck {
if permission_type == PermissionType::PluginStorage {
let rule = plugins_config
.get(plugin_id)
.and_then(|config| config.permissions.plugin_storage.as_ref());
return match rule {
Some(rule) => check_storage_permission(rule),
None => PermissionCheck::Allowed,
};
}
let config = match plugins_config.get(plugin_id) {
Some(c) => c,
None => return PermissionCheck::NotConfigured,
};
let rule = match get_permission_rule(&config.permissions, permission_type) {
Some(r) => r,
None => return PermissionCheck::NotConfigured,
};
match permission_type {
PermissionType::HttpRequests => check_http_permission(rule, target),
PermissionType::PluginStorage => check_storage_permission(rule),
_ => check_file_permission(rule, target),
}
}
fn extract_domain(url: &str) -> String {
let without_scheme = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(url);
let domain = without_scheme.split('/').next().unwrap_or(without_scheme);
domain.split(':').next().unwrap_or(domain).to_lowercase()
}
fn domain_matches(domain: &str, pattern: &str) -> bool {
let pattern_lower = pattern.to_lowercase();
if domain == pattern_lower {
return true;
}
domain.ends_with(&format!(".{}", pattern_lower))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_path_from_scope_markdown_link() {
assert_eq!(
extract_path_from_scope("[Daily](/journal/daily/daily.md)"),
Some("journal/daily/daily.md")
);
}
#[test]
fn test_extract_path_from_scope_root_path() {
assert_eq!(
extract_path_from_scope("/journal/daily/daily.md"),
Some("journal/daily/daily.md")
);
}
#[test]
fn test_extract_path_from_scope_relative_path() {
assert_eq!(
extract_path_from_scope("journal/daily/daily.md"),
Some("journal/daily/daily.md")
);
}
#[test]
fn test_check_file_permission_all_include() {
let rule = PermissionRule {
include: vec!["all".to_string()],
exclude: vec![],
};
assert_eq!(
check_file_permission(&rule, "journal/2026-03-02.md"),
PermissionCheck::Allowed
);
}
#[test]
fn test_check_file_permission_all_with_exclude() {
let rule = PermissionRule {
include: vec!["all".to_string()],
exclude: vec".to_string()],
};
assert_eq!(
check_file_permission(&rule, "journal/2026-03-02.md"),
PermissionCheck::Allowed
);
assert_eq!(
check_file_permission(&rule, "private/sensitive.md"),
PermissionCheck::Denied
);
assert_eq!(
check_file_permission(&rule, "private/sensitive/secret.md"),
PermissionCheck::Denied
);
}
#[test]
fn test_check_file_permission_specific_folder() {
let rule = PermissionRule {
include: vec".to_string()],
exclude: vec![],
};
assert_eq!(
check_file_permission(&rule, "journal/daily/2026-03-02.md"),
PermissionCheck::Allowed
);
assert_eq!(
check_file_permission(&rule, "private/notes.md"),
PermissionCheck::NotConfigured
);
}
#[test]
fn test_check_file_permission_exact_file() {
let rule = PermissionRule {
include: vec".to_string()],
exclude: vec![],
};
assert_eq!(
check_file_permission(&rule, "projects/todo.md"),
PermissionCheck::Allowed
);
assert_eq!(
check_file_permission(&rule, "projects/other.md"),
PermissionCheck::Allowed );
}
#[test]
fn test_check_file_permission_no_match() {
let rule = PermissionRule {
include: vec".to_string()],
exclude: vec![],
};
assert_eq!(
check_file_permission(&rule, "private/secret.md"),
PermissionCheck::NotConfigured
);
}
#[test]
fn test_check_file_permission_exclude_wins() {
let rule = PermissionRule {
include: vec!["all".to_string()],
exclude: vec".to_string()],
};
assert_eq!(
check_file_permission(&rule, "private/notes.md"),
PermissionCheck::Denied
);
}
#[test]
fn test_check_http_permission_specific_domains() {
let rule = PermissionRule {
include: vec!["openrouter.ai".to_string(), "api.anthropic.com".to_string()],
exclude: vec![],
};
assert_eq!(
check_http_permission(&rule, "https://openrouter.ai/v1/chat"),
PermissionCheck::Allowed
);
assert_eq!(
check_http_permission(&rule, "https://api.anthropic.com/v1/messages"),
PermissionCheck::Allowed
);
assert_eq!(
check_http_permission(&rule, "https://evil.example.com/data"),
PermissionCheck::NotConfigured
);
}
#[test]
fn test_check_http_permission_all() {
let rule = PermissionRule {
include: vec!["all".to_string()],
exclude: vec![],
};
assert_eq!(
check_http_permission(&rule, "https://anything.com/path"),
PermissionCheck::Allowed
);
}
#[test]
fn test_check_storage_permission() {
let rule = PermissionRule {
include: vec!["all".to_string()],
exclude: vec![],
};
assert_eq!(check_storage_permission(&rule), PermissionCheck::Allowed);
let empty_rule = PermissionRule {
include: vec![],
exclude: vec![],
};
assert_eq!(
check_storage_permission(&empty_rule),
PermissionCheck::NotConfigured
);
}
#[test]
fn test_check_permission_missing_plugin() {
let config: HashMap<String, PluginConfig> = HashMap::new();
assert_eq!(
check_permission(&config, "diaryx.ai", PermissionType::ReadFiles, "file.md"),
PermissionCheck::NotConfigured
);
}
#[test]
fn test_check_permission_missing_rule() {
let mut config = HashMap::new();
config.insert(
"diaryx.ai".to_string(),
PluginConfig {
download: None,
permissions: PluginPermissions::default(),
},
);
assert_eq!(
check_permission(&config, "diaryx.ai", PermissionType::ReadFiles, "file.md"),
PermissionCheck::NotConfigured
);
}
#[test]
fn test_check_permission_full_config() {
let mut config = HashMap::new();
config.insert(
"diaryx.ai".to_string(),
PluginConfig {
download: Some("https://app.diaryx.org/cdn/plugins/diaryx_ai".to_string()),
permissions: PluginPermissions {
read_files: Some(PermissionRule {
include: vec".to_string()],
exclude: vec".to_string()],
}),
http_requests: Some(PermissionRule {
include: vec!["openrouter.ai".to_string()],
exclude: vec![],
}),
plugin_storage: Some(PermissionRule {
include: vec!["all".to_string()],
exclude: vec![],
}),
..Default::default()
},
},
);
assert_eq!(
check_permission(
&config,
"diaryx.ai",
PermissionType::ReadFiles,
"journal/daily/2026-03-02.md"
),
PermissionCheck::Allowed
);
assert_eq!(
check_permission(
&config,
"diaryx.ai",
PermissionType::ReadFiles,
"private/sensitive.md"
),
PermissionCheck::Denied
);
assert_eq!(
check_permission(
&config,
"diaryx.ai",
PermissionType::EditFiles,
"journal/daily/2026-03-02.md"
),
PermissionCheck::NotConfigured
);
assert_eq!(
check_permission(
&config,
"diaryx.ai",
PermissionType::HttpRequests,
"https://openrouter.ai/v1/chat"
),
PermissionCheck::Allowed
);
assert_eq!(
check_permission(&config, "diaryx.ai", PermissionType::PluginStorage, ""),
PermissionCheck::Allowed
);
}
#[test]
fn test_extract_domain() {
assert_eq!(
extract_domain("https://openrouter.ai/v1/chat"),
"openrouter.ai"
);
assert_eq!(
extract_domain("https://api.anthropic.com/v1/messages"),
"api.anthropic.com"
);
assert_eq!(extract_domain("http://localhost:8080/api"), "localhost");
assert_eq!(extract_domain("openrouter.ai"), "openrouter.ai");
}
#[test]
fn test_domain_matches_suffix() {
assert!(domain_matches("api.openrouter.ai", "openrouter.ai"));
assert!(!domain_matches("notopenrouter.ai", "openrouter.ai"));
assert!(domain_matches("openrouter.ai", "openrouter.ai"));
}
#[test]
fn plugin_storage_defaults_to_allowed_when_not_configured() {
let config = HashMap::new();
assert_eq!(
check_permission(&config, "diaryx.test", PermissionType::PluginStorage, ""),
PermissionCheck::Allowed
);
}
#[test]
fn test_yaml_round_trip() {
let config = PluginConfig {
download: Some("https://app.diaryx.org/cdn/plugins/diaryx_ai".to_string()),
permissions: PluginPermissions {
read_files: Some(PermissionRule {
include: vec".to_string(),
"[Utility](/utility/utility.md)".to_string(),
],
exclude: vec".to_string()],
}),
edit_files: Some(PermissionRule {
include: vec".to_string()],
exclude: vec![],
}),
http_requests: Some(PermissionRule {
include: vec!["openrouter.ai".to_string(), "api.anthropic.com".to_string()],
exclude: vec![],
}),
plugin_storage: Some(PermissionRule {
include: vec!["all".to_string()],
exclude: vec![],
}),
..Default::default()
},
};
let yaml = serde_yaml::to_string(&config).unwrap();
let parsed: PluginConfig = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(parsed.download, config.download);
assert!(parsed.permissions.read_files.is_some());
let read = parsed.permissions.read_files.unwrap();
assert_eq!(read.include.len(), 2);
assert_eq!(read.exclude.len(), 1);
}
#[test]
fn test_leading_slash_normalization() {
let rule = PermissionRule {
include: vec!["all".to_string()],
exclude: vec![],
};
assert_eq!(
check_file_permission(&rule, "/journal/file.md"),
PermissionCheck::Allowed
);
assert_eq!(
check_file_permission(&rule, "journal/file.md"),
PermissionCheck::Allowed
);
}
}