use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::NapError;
use crate::vcs::{AccessLevel, Permission};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GateConfig {
#[serde(default = "default_deny")]
default: String,
#[serde(default)]
rules: Vec<GateRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GateRule {
path: String,
principal: String,
access: String,
}
fn default_deny() -> String {
"deny".to_string()
}
#[derive(Debug)]
pub struct PermissionGate {
workspace_root: PathBuf,
rules: Vec<(PathBuf, String, AccessLevel)>,
cache: std::sync::Mutex<HashMap<(PathBuf, String), AccessLevel>>,
default: AccessLevel,
}
impl PermissionGate {
pub fn load(workspace_root: &Path) -> Result<Self, NapError> {
let config_path = workspace_root.join("context").join("nap-gate.toml");
if !config_path.exists() {
return Ok(Self {
workspace_root: workspace_root.to_path_buf(),
rules: Vec::new(),
cache: std::sync::Mutex::new(HashMap::new()),
default: AccessLevel::Write,
});
}
let contents = std::fs::read_to_string(&config_path).map_err(|e| {
NapError::Other(format!(
"failed to read gate config at {:?}: {}",
config_path, e
))
})?;
let config: GateConfig = toml::from_str(&contents).map_err(|e| {
NapError::Other(format!(
"failed to parse gate config at {:?}: {}",
config_path, e
))
})?;
let default = match config.default.as_str() {
"read" => AccessLevel::Read,
"write" => AccessLevel::Write,
"deny" => AccessLevel::None,
other => {
return Err(NapError::Other(format!(
"unknown default access level '{}' in gate config at {:?}",
other, config_path
)));
}
};
let mut rules: Vec<(PathBuf, String, AccessLevel)> = Vec::new();
for rule in &config.rules {
let access = match rule.access.as_str() {
"read" => AccessLevel::Read,
"write" => AccessLevel::Write,
"deny" | "none" => AccessLevel::None,
other => {
return Err(NapError::Other(format!(
"unknown access level '{}' in rule for path '{}'",
other, rule.path
)));
}
};
rules.push((PathBuf::from(&rule.path), rule.principal.clone(), access));
}
Ok(Self {
workspace_root: workspace_root.to_path_buf(),
rules,
cache: std::sync::Mutex::new(HashMap::new()),
default,
})
}
pub fn strict(workspace_root: &Path) -> Self {
Self {
workspace_root: workspace_root.to_path_buf(),
rules: Vec::new(),
cache: std::sync::Mutex::new(HashMap::new()),
default: AccessLevel::None,
}
}
pub fn permissive(workspace_root: &Path) -> Self {
Self {
workspace_root: workspace_root.to_path_buf(),
rules: Vec::new(),
cache: std::sync::Mutex::new(HashMap::new()),
default: AccessLevel::Write,
}
}
pub fn from_permissions(
workspace_root: &Path,
permissions: &[Permission],
default: AccessLevel,
) -> Self {
let rules: Vec<(PathBuf, String, AccessLevel)> = permissions
.iter()
.map(|p| (PathBuf::from(&p.path_prefix), p.principal.clone(), p.access))
.collect();
Self {
workspace_root: workspace_root.to_path_buf(),
rules,
cache: std::sync::Mutex::new(HashMap::new()),
default,
}
}
pub fn check_read(&self, path: &str, principal: &str) -> Result<(), NapError> {
self.check(path, principal, AccessLevel::Read)
}
pub fn check_write(&self, path: &str, principal: &str) -> Result<(), NapError> {
self.check(path, principal, AccessLevel::Write)
}
fn check(&self, path: &str, principal: &str, required: AccessLevel) -> Result<(), NapError> {
let cache_key = (PathBuf::from(path), principal.to_string());
{
let cache = self.cache.lock().unwrap();
if let Some(&granted) = cache.get(&cache_key) {
if granted == AccessLevel::Write {
return Ok(());
}
if granted == AccessLevel::Read && required == AccessLevel::Read {
return Ok(());
}
return Err(NapError::PermissionDenied(format!(
"access denied to '{}' for '{}' (cached)",
path, principal
)));
}
}
let result = self.check_uncached(path, principal, required);
let granted = self
.check_uncached(path, principal, AccessLevel::Read)
.map(|_| {
if self
.check_uncached(path, principal, AccessLevel::Write)
.is_ok()
{
AccessLevel::Write
} else {
AccessLevel::Read
}
})
.unwrap_or(AccessLevel::None);
{
let mut cache = self.cache.lock().unwrap();
cache.insert(cache_key, granted);
}
result
}
fn check_uncached(
&self,
path: &str,
principal: &str,
required: AccessLevel,
) -> Result<(), NapError> {
let norm_path = path.strip_prefix('/').unwrap_or(path);
let norm_prefix = |p: &Path| -> PathBuf {
let s = p.to_string_lossy();
let relative = s.strip_prefix('/').unwrap_or(&s);
self.workspace_root.join(relative)
};
let request_path = self.workspace_root.join(norm_path);
let mut best_match: Option<(usize, &AccessLevel)> = None;
let mut best_prefix_len: usize = 0;
for (rule_prefix, rule_principal, rule_access) in &self.rules {
let prefix = norm_prefix(rule_prefix);
if !request_path.starts_with(&prefix) {
continue;
}
if rule_principal != "*" && rule_principal != principal {
continue;
}
let prefix_len = prefix.as_os_str().len();
if best_match.is_none() || prefix_len > best_prefix_len {
best_match = Some((prefix_len, rule_access));
best_prefix_len = prefix_len;
}
}
if let Some((_, access)) = best_match {
match access {
AccessLevel::None => {
return Err(NapError::PermissionDenied(format!(
"principal '{}' is denied access to '{}'",
principal, path
)));
}
AccessLevel::Read => {
if required == AccessLevel::Write {
return Err(NapError::PermissionDenied(format!(
"principal '{}' has read-only access to '{}'",
principal, path
)));
}
return Ok(());
}
AccessLevel::Write => {
return Ok(());
}
}
}
match self.default {
AccessLevel::None => Err(NapError::PermissionDenied(format!(
"principal '{}' is denied access to '{}' (default deny)",
principal, path
))),
AccessLevel::Read => {
if required == AccessLevel::Write {
return Err(NapError::PermissionDenied(format!(
"principal '{}' has read-only access to '{}' (default read)",
principal, path
)));
}
Ok(())
}
AccessLevel::Write => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_permissive_gate_allows_all() {
let dir = tempfile::TempDir::new().unwrap();
let gate = PermissionGate::permissive(dir.path());
assert!(gate.check_read("/any/path", "alice").is_ok());
assert!(gate.check_write("/any/path", "alice").is_ok());
assert!(gate.check_write("/any/path", "mallory").is_ok());
}
#[test]
fn test_strict_gate_denies_all() {
let dir = tempfile::TempDir::new().unwrap();
let gate = PermissionGate::strict(dir.path());
assert!(gate.check_read("/any/path", "alice").is_err());
assert!(gate.check_write("/any/path", "alice").is_err());
}
#[test]
fn test_from_permissions() {
let dir = tempfile::TempDir::new().unwrap();
let perms = vec![
Permission {
path_prefix: "/public".to_string(),
principal: "*".to_string(),
access: AccessLevel::Read,
},
Permission {
path_prefix: "/admin".to_string(),
principal: "alice".to_string(),
access: AccessLevel::Write,
},
];
let gate = PermissionGate::from_permissions(dir.path(), &perms, AccessLevel::None);
assert!(gate.check_read("/public/readme.md", "bob").is_ok());
assert!(gate.check_write("/public/readme.md", "bob").is_err());
assert!(gate.check_write("/admin/secret.md", "alice").is_ok());
assert!(gate.check_write("/admin/secret.md", "bob").is_err());
assert!(gate.check_read("/other", "alice").is_err());
}
#[test]
fn test_load_from_file() {
let dir = tempfile::TempDir::new().unwrap();
let context_dir = dir.path().join("context");
fs::create_dir_all(&context_dir).unwrap();
let config = r#"
default = "deny"
[[rules]]
path = "entities"
principal = "*"
access = "read"
[[rules]]
path = "entities/characters"
principal = "alice"
access = "write"
"#;
fs::write(context_dir.join("nap-gate.toml"), config).unwrap();
let gate = PermissionGate::load(dir.path()).unwrap();
assert!(gate.check_read("entities/foo.yaml", "bob").is_ok());
assert!(gate.check_write("entities/foo.yaml", "bob").is_err());
assert!(gate.check_write("entities/foo.yaml", "alice").is_err()); assert!(
gate.check_write("entities/characters/hero.yaml", "alice")
.is_ok()
);
assert!(gate.check_read("context/secret.md", "alice").is_err());
}
#[test]
fn test_cache_hits() {
let dir = tempfile::TempDir::new().unwrap();
let gate = PermissionGate::permissive(dir.path());
assert!(gate.check_read("/file", "alice").is_ok());
assert!(gate.check_read("/file", "alice").is_ok());
}
#[test]
fn test_cache_miss_different_principal() {
let dir = tempfile::TempDir::new().unwrap();
let perms = vec![Permission {
path_prefix: "/".to_string(),
principal: "alice".to_string(),
access: AccessLevel::Write,
}];
let gate = PermissionGate::from_permissions(dir.path(), &perms, AccessLevel::None);
assert!(gate.check_read("/file", "alice").is_ok());
assert!(gate.check_read("/file", "bob").is_err());
}
#[test]
fn test_invalid_config_default() {
let dir = tempfile::TempDir::new().unwrap();
let context_dir = dir.path().join("context");
fs::create_dir_all(&context_dir).unwrap();
fs::write(
context_dir.join("nap-gate.toml"),
r#"default = "superadmin""#,
)
.unwrap();
let result = PermissionGate::load(dir.path());
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("unknown default"),
"expected 'unknown default' error"
);
}
}