1use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31
32use serde::{Deserialize, Serialize};
33
34use crate::error::NapError;
35use crate::vcs::{AccessLevel, Permission};
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
43struct GateConfig {
44 #[serde(default = "default_deny")]
46 default: String,
47 #[serde(default)]
49 rules: Vec<GateRule>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53struct GateRule {
54 path: String,
56 principal: String,
58 access: String,
60}
61
62fn default_deny() -> String {
63 "deny".to_string()
64}
65
66#[derive(Debug)]
85pub struct PermissionGate {
86 workspace_root: PathBuf,
88 rules: Vec<(PathBuf, String, AccessLevel)>,
91 cache: std::sync::Mutex<HashMap<(PathBuf, String), AccessLevel>>,
94 default: AccessLevel,
96}
97
98impl PermissionGate {
99 pub fn load(workspace_root: &Path) -> Result<Self, NapError> {
105 let config_path = workspace_root.join("context").join("nap-gate.toml");
106 if !config_path.exists() {
107 return Ok(Self {
109 workspace_root: workspace_root.to_path_buf(),
110 rules: Vec::new(),
111 cache: std::sync::Mutex::new(HashMap::new()),
112 default: AccessLevel::Write,
113 });
114 }
115
116 let contents = std::fs::read_to_string(&config_path).map_err(|e| {
117 NapError::Other(format!(
118 "failed to read gate config at {:?}: {}",
119 config_path, e
120 ))
121 })?;
122
123 let config: GateConfig = toml::from_str(&contents).map_err(|e| {
124 NapError::Other(format!(
125 "failed to parse gate config at {:?}: {}",
126 config_path, e
127 ))
128 })?;
129
130 let default = match config.default.as_str() {
131 "read" => AccessLevel::Read,
132 "write" => AccessLevel::Write,
133 "deny" => AccessLevel::None,
134 other => {
135 return Err(NapError::Other(format!(
136 "unknown default access level '{}' in gate config at {:?}",
137 other, config_path
138 )));
139 }
140 };
141
142 let mut rules: Vec<(PathBuf, String, AccessLevel)> = Vec::new();
143 for rule in &config.rules {
144 let access = match rule.access.as_str() {
145 "read" => AccessLevel::Read,
146 "write" => AccessLevel::Write,
147 "deny" | "none" => AccessLevel::None,
148 other => {
149 return Err(NapError::Other(format!(
150 "unknown access level '{}' in rule for path '{}'",
151 other, rule.path
152 )));
153 }
154 };
155 rules.push((PathBuf::from(&rule.path), rule.principal.clone(), access));
156 }
157
158 Ok(Self {
159 workspace_root: workspace_root.to_path_buf(),
160 rules,
161 cache: std::sync::Mutex::new(HashMap::new()),
162 default,
163 })
164 }
165
166 pub fn strict(workspace_root: &Path) -> Self {
170 Self {
171 workspace_root: workspace_root.to_path_buf(),
172 rules: Vec::new(),
173 cache: std::sync::Mutex::new(HashMap::new()),
174 default: AccessLevel::None,
175 }
176 }
177
178 pub fn permissive(workspace_root: &Path) -> Self {
181 Self {
182 workspace_root: workspace_root.to_path_buf(),
183 rules: Vec::new(),
184 cache: std::sync::Mutex::new(HashMap::new()),
185 default: AccessLevel::Write,
186 }
187 }
188
189 pub fn from_permissions(
191 workspace_root: &Path,
192 permissions: &[Permission],
193 default: AccessLevel,
194 ) -> Self {
195 let rules: Vec<(PathBuf, String, AccessLevel)> = permissions
196 .iter()
197 .map(|p| (PathBuf::from(&p.path_prefix), p.principal.clone(), p.access))
198 .collect();
199
200 Self {
201 workspace_root: workspace_root.to_path_buf(),
202 rules,
203 cache: std::sync::Mutex::new(HashMap::new()),
204 default,
205 }
206 }
207
208 pub fn check_read(&self, path: &str, principal: &str) -> Result<(), NapError> {
210 self.check(path, principal, AccessLevel::Read)
211 }
212
213 pub fn check_write(&self, path: &str, principal: &str) -> Result<(), NapError> {
215 self.check(path, principal, AccessLevel::Write)
216 }
217
218 fn check(&self, path: &str, principal: &str, required: AccessLevel) -> Result<(), NapError> {
221 let cache_key = (PathBuf::from(path), principal.to_string());
222 {
223 let cache = self.cache.lock().unwrap();
224 if let Some(&granted) = cache.get(&cache_key) {
225 if granted == AccessLevel::Write {
228 return Ok(());
229 }
230 if granted == AccessLevel::Read && required == AccessLevel::Read {
231 return Ok(());
232 }
233 return Err(NapError::PermissionDenied(format!(
234 "access denied to '{}' for '{}' (cached)",
235 path, principal
236 )));
237 }
238 }
239
240 let result = self.check_uncached(path, principal, required);
241 let granted = self
243 .check_uncached(path, principal, AccessLevel::Read)
244 .map(|_| {
245 if self
247 .check_uncached(path, principal, AccessLevel::Write)
248 .is_ok()
249 {
250 AccessLevel::Write
251 } else {
252 AccessLevel::Read
253 }
254 })
255 .unwrap_or(AccessLevel::None);
256
257 {
258 let mut cache = self.cache.lock().unwrap();
259 cache.insert(cache_key, granted);
260 }
261
262 result
263 }
264
265 fn check_uncached(
266 &self,
267 path: &str,
268 principal: &str,
269 required: AccessLevel,
270 ) -> Result<(), NapError> {
271 let norm_path = path.strip_prefix('/').unwrap_or(path);
274 let norm_prefix = |p: &Path| -> PathBuf {
275 let s = p.to_string_lossy();
276 let relative = s.strip_prefix('/').unwrap_or(&s);
277 self.workspace_root.join(relative)
278 };
279
280 let request_path = self.workspace_root.join(norm_path);
281
282 let mut best_match: Option<(usize, &AccessLevel)> = None;
286 let mut best_prefix_len: usize = 0;
287
288 for (rule_prefix, rule_principal, rule_access) in &self.rules {
289 let prefix = norm_prefix(rule_prefix);
290
291 if !request_path.starts_with(&prefix) {
292 continue;
293 }
294
295 if rule_principal != "*" && rule_principal != principal {
296 continue;
297 }
298
299 let prefix_len = prefix.as_os_str().len();
300 if best_match.is_none() || prefix_len > best_prefix_len {
301 best_match = Some((prefix_len, rule_access));
302 best_prefix_len = prefix_len;
303 }
304 }
305
306 if let Some((_, access)) = best_match {
308 match access {
309 AccessLevel::None => {
310 return Err(NapError::PermissionDenied(format!(
311 "principal '{}' is denied access to '{}'",
312 principal, path
313 )));
314 }
315 AccessLevel::Read => {
316 if required == AccessLevel::Write {
317 return Err(NapError::PermissionDenied(format!(
318 "principal '{}' has read-only access to '{}'",
319 principal, path
320 )));
321 }
322 return Ok(());
323 }
324 AccessLevel::Write => {
325 return Ok(());
326 }
327 }
328 }
329
330 match self.default {
332 AccessLevel::None => Err(NapError::PermissionDenied(format!(
333 "principal '{}' is denied access to '{}' (default deny)",
334 principal, path
335 ))),
336 AccessLevel::Read => {
337 if required == AccessLevel::Write {
338 return Err(NapError::PermissionDenied(format!(
339 "principal '{}' has read-only access to '{}' (default read)",
340 principal, path
341 )));
342 }
343 Ok(())
344 }
345 AccessLevel::Write => Ok(()),
346 }
347 }
348}
349
350#[cfg(test)]
355mod tests {
356 use super::*;
357 use std::fs;
358
359 #[test]
360 fn test_permissive_gate_allows_all() {
361 let dir = tempfile::TempDir::new().unwrap();
362 let gate = PermissionGate::permissive(dir.path());
363 assert!(gate.check_read("/any/path", "alice").is_ok());
364 assert!(gate.check_write("/any/path", "alice").is_ok());
365 assert!(gate.check_write("/any/path", "mallory").is_ok());
366 }
367
368 #[test]
369 fn test_strict_gate_denies_all() {
370 let dir = tempfile::TempDir::new().unwrap();
371 let gate = PermissionGate::strict(dir.path());
372 assert!(gate.check_read("/any/path", "alice").is_err());
373 assert!(gate.check_write("/any/path", "alice").is_err());
374 }
375
376 #[test]
377 fn test_from_permissions() {
378 let dir = tempfile::TempDir::new().unwrap();
379 let perms = vec![
380 Permission {
381 path_prefix: "/public".to_string(),
382 principal: "*".to_string(),
383 access: AccessLevel::Read,
384 },
385 Permission {
386 path_prefix: "/admin".to_string(),
387 principal: "alice".to_string(),
388 access: AccessLevel::Write,
389 },
390 ];
391 let gate = PermissionGate::from_permissions(dir.path(), &perms, AccessLevel::None);
392
393 assert!(gate.check_read("/public/readme.md", "bob").is_ok());
395 assert!(gate.check_write("/public/readme.md", "bob").is_err());
397 assert!(gate.check_write("/admin/secret.md", "alice").is_ok());
399 assert!(gate.check_write("/admin/secret.md", "bob").is_err());
401 assert!(gate.check_read("/other", "alice").is_err());
403 }
404
405 #[test]
406 fn test_load_from_file() {
407 let dir = tempfile::TempDir::new().unwrap();
408 let context_dir = dir.path().join("context");
409 fs::create_dir_all(&context_dir).unwrap();
410
411 let config = r#"
412default = "deny"
413
414[[rules]]
415path = "entities"
416principal = "*"
417access = "read"
418
419[[rules]]
420path = "entities/characters"
421principal = "alice"
422access = "write"
423"#;
424 fs::write(context_dir.join("nap-gate.toml"), config).unwrap();
425
426 let gate = PermissionGate::load(dir.path()).unwrap();
427
428 assert!(gate.check_read("entities/foo.yaml", "bob").is_ok());
430 assert!(gate.check_write("entities/foo.yaml", "bob").is_err());
432 assert!(gate.check_write("entities/foo.yaml", "alice").is_err()); assert!(
434 gate.check_write("entities/characters/hero.yaml", "alice")
435 .is_ok()
436 );
437 assert!(gate.check_read("context/secret.md", "alice").is_err());
439 }
440
441 #[test]
442 fn test_cache_hits() {
443 let dir = tempfile::TempDir::new().unwrap();
444 let gate = PermissionGate::permissive(dir.path());
445
446 assert!(gate.check_read("/file", "alice").is_ok());
448 assert!(gate.check_read("/file", "alice").is_ok());
450 }
451
452 #[test]
453 fn test_cache_miss_different_principal() {
454 let dir = tempfile::TempDir::new().unwrap();
455 let perms = vec![Permission {
456 path_prefix: "/".to_string(),
457 principal: "alice".to_string(),
458 access: AccessLevel::Write,
459 }];
460 let gate = PermissionGate::from_permissions(dir.path(), &perms, AccessLevel::None);
461
462 assert!(gate.check_read("/file", "alice").is_ok());
463 assert!(gate.check_read("/file", "bob").is_err());
465 }
466
467 #[test]
468 fn test_invalid_config_default() {
469 let dir = tempfile::TempDir::new().unwrap();
470 let context_dir = dir.path().join("context");
471 fs::create_dir_all(&context_dir).unwrap();
472 fs::write(
473 context_dir.join("nap-gate.toml"),
474 r#"default = "superadmin""#,
475 )
476 .unwrap();
477
478 let result = PermissionGate::load(dir.path());
479 assert!(result.is_err());
480 assert!(
481 result.unwrap_err().to_string().contains("unknown default"),
482 "expected 'unknown default' error"
483 );
484 }
485}