1use crate::config::Config;
9use crate::error::{Error, Result};
10use chrono::{DateTime, Utc};
11use fs4::fs_std::FileExt;
12use serde::{Deserialize, Serialize};
13use std::collections::{BTreeMap, HashMap};
14use std::fs::File;
15use std::io::Write as _;
16use std::path::{Path, PathBuf};
17use std::sync::{Mutex, OnceLock};
18use std::time::{Duration, Instant};
19use tempfile::NamedTempFile;
20
21const CURRENT_VERSION: u32 = 1;
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct PersistedDecision {
31 pub capability: String,
33
34 pub allow: bool,
36
37 pub decided_at: String,
39
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub expires_at: Option<String>,
43
44 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub version_range: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53struct PermissionsFile {
54 version: u32,
55 decisions: BTreeMap<String, Vec<PersistedDecision>>,
57}
58
59impl Default for PermissionsFile {
60 fn default() -> Self {
61 Self {
62 version: CURRENT_VERSION,
63 decisions: BTreeMap::new(),
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
74pub struct PermissionStore {
75 path: PathBuf,
76 decisions: HashMap<String, HashMap<String, PersistedDecision>>,
78}
79
80impl PermissionStore {
81 pub fn open_default() -> Result<Self> {
83 Self::open(&Config::permissions_path())
84 }
85
86 pub(crate) fn empty_at(path: &Path) -> Self {
87 Self {
88 path: path.to_path_buf(),
89 decisions: HashMap::new(),
90 }
91 }
92
93 pub fn open(path: &Path) -> Result<Self> {
95 let decisions = load_permissions_decisions(path)?;
96
97 Ok(Self {
98 path: path.to_path_buf(),
99 decisions,
100 })
101 }
102
103 pub fn lookup(&self, extension_id: &str, capability: &str) -> Option<bool> {
108 let by_cap = self.decisions.get(extension_id)?;
109 let dec = by_cap.get(capability)?;
110
111 if !decision_is_active(dec, Utc::now()) {
112 return None;
113 }
114
115 Some(dec.allow)
116 }
117
118 pub fn record(&mut self, extension_id: &str, capability: &str, allow: bool) -> Result<()> {
120 let decision = PersistedDecision {
121 capability: capability.to_string(),
122 allow,
123 decided_at: now_iso8601(),
124 expires_at: None,
125 version_range: None,
126 };
127
128 self.update_persisted_decisions(|decisions| {
129 decisions
130 .entry(extension_id.to_string())
131 .or_default()
132 .insert(capability.to_string(), decision);
133 })
134 }
135
136 pub fn record_with_version(
138 &mut self,
139 extension_id: &str,
140 capability: &str,
141 allow: bool,
142 version_range: &str,
143 ) -> Result<()> {
144 let decision = PersistedDecision {
145 capability: capability.to_string(),
146 allow,
147 decided_at: now_iso8601(),
148 expires_at: None,
149 version_range: Some(version_range.to_string()),
150 };
151
152 self.update_persisted_decisions(|decisions| {
153 decisions
154 .entry(extension_id.to_string())
155 .or_default()
156 .insert(capability.to_string(), decision);
157 })
158 }
159
160 pub fn revoke_extension(&mut self, extension_id: &str) -> Result<()> {
162 self.update_persisted_decisions(|decisions| {
163 decisions.remove(extension_id);
164 })
165 }
166
167 pub fn reset(&mut self) -> Result<()> {
169 self.update_persisted_decisions(HashMap::clear)
170 }
171
172 pub const fn list(&self) -> &HashMap<String, HashMap<String, PersistedDecision>> {
174 &self.decisions
175 }
176
177 pub fn to_cache_map(&self) -> HashMap<String, HashMap<String, bool>> {
182 let now = Utc::now();
183 self.decisions
184 .iter()
185 .map(|(ext_id, by_cap)| {
186 let filtered: HashMap<String, bool> = by_cap
187 .iter()
188 .filter(|(_, dec)| decision_is_active(dec, now))
189 .map(|(cap, dec)| (cap.clone(), dec.allow))
190 .collect();
191 (ext_id.clone(), filtered)
192 })
193 .filter(|(_, m)| !m.is_empty())
194 .collect()
195 }
196
197 pub fn to_decision_cache(&self) -> HashMap<String, HashMap<String, PersistedDecision>> {
200 let now = Utc::now();
201 self.decisions
202 .iter()
203 .map(|(ext_id, by_cap)| {
204 let filtered: HashMap<String, PersistedDecision> = by_cap
205 .iter()
206 .filter(|(_, dec)| decision_is_active(dec, now))
207 .map(|(cap, dec)| (cap.clone(), dec.clone()))
208 .collect();
209 (ext_id.clone(), filtered)
210 })
211 .filter(|(_, m)| !m.is_empty())
212 .collect()
213 }
214
215 fn update_persisted_decisions(
220 &mut self,
221 update: impl FnOnce(&mut HashMap<String, HashMap<String, PersistedDecision>>),
222 ) -> Result<()> {
223 let _process_guard = permissions_persist_lock()
224 .lock()
225 .unwrap_or_else(std::sync::PoisonError::into_inner);
226 let lock_handle = open_permissions_lock_file(&self.path)?;
227 let _file_guard = lock_permissions_file(lock_handle, Duration::from_secs(30))?;
228
229 self.decisions = load_permissions_decisions(&self.path)?;
230 update(&mut self.decisions);
231 self.save_unlocked()
232 }
233
234 fn save_unlocked(&self) -> Result<()> {
236 let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
237 if !parent.as_os_str().is_empty() {
238 std::fs::create_dir_all(parent)?;
239 }
240
241 let file = PermissionsFile {
243 version: CURRENT_VERSION,
244 decisions: {
245 let mut extension_ids = self.decisions.keys().cloned().collect::<Vec<_>>();
246 extension_ids.sort();
247 extension_ids
248 .into_iter()
249 .map(|extension_id| {
250 let by_cap = self
251 .decisions
252 .get(&extension_id)
253 .expect("extension id collected from decision map");
254 let mut decisions = by_cap.values().cloned().collect::<Vec<_>>();
255 decisions.sort_by(|left, right| left.capability.cmp(&right.capability));
256 (extension_id, decisions)
257 })
258 .collect()
259 },
260 };
261
262 let mut contents = serde_json::to_string_pretty(&file)?;
263 contents.push('\n');
264
265 let mut tmp = NamedTempFile::new_in(parent)?;
266
267 #[cfg(unix)]
268 {
269 use std::os::unix::fs::PermissionsExt as _;
270 let perms = std::fs::Permissions::from_mode(0o600);
271 tmp.as_file().set_permissions(perms)?;
272 }
273
274 tmp.write_all(contents.as_bytes())?;
275 tmp.as_file().sync_all()?;
276
277 tmp.persist(&self.path).map_err(|err| {
278 Error::config(format!(
279 "Failed to persist permissions file to {}: {}",
280 self.path.display(),
281 err.error
282 ))
283 })?;
284 sync_permissions_parent_dir(&self.path)?;
285
286 Ok(())
287 }
288}
289
290fn load_permissions_decisions(
295 path: &Path,
296) -> Result<HashMap<String, HashMap<String, PersistedDecision>>> {
297 if !path.exists() {
298 return Ok(HashMap::new());
299 }
300
301 let raw = std::fs::read_to_string(path).map_err(|e| {
302 Error::config(format!(
303 "Failed to read permissions file {}: {e}",
304 path.display()
305 ))
306 })?;
307 let file: PermissionsFile = serde_json::from_str(&raw).map_err(|e| {
308 Error::config(format!(
309 "Failed to parse permissions file {}: {e}",
310 path.display()
311 ))
312 })?;
313 if file.version != CURRENT_VERSION {
314 return Err(Error::config(format!(
315 "Unsupported permissions file schema version {} in {} (expected {})",
316 file.version,
317 path.display(),
318 CURRENT_VERSION
319 )));
320 }
321
322 Ok(file
323 .decisions
324 .into_iter()
325 .map(|(ext_id, decs)| {
326 let by_cap: HashMap<String, PersistedDecision> = decs
327 .into_iter()
328 .map(|d| (d.capability.clone(), d))
329 .collect();
330 (ext_id, by_cap)
331 })
332 .collect())
333}
334
335fn permissions_persist_lock() -> &'static Mutex<()> {
336 static PERSIST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
337 PERSIST_LOCK.get_or_init(|| Mutex::new(()))
338}
339
340fn permissions_lock_path(path: &Path) -> PathBuf {
341 let mut lock_path = path.to_path_buf();
342 let mut file_name = path.file_name().map_or_else(
343 || std::ffi::OsString::from("permissions"),
344 std::ffi::OsString::from,
345 );
346 file_name.push(".lock");
347 lock_path.set_file_name(file_name);
348 lock_path
349}
350
351fn open_permissions_lock_file(path: &Path) -> Result<File> {
352 let lock_path = permissions_lock_path(path);
353 if let Some(parent) = lock_path.parent()
354 && !parent.as_os_str().is_empty()
355 {
356 std::fs::create_dir_all(parent)?;
357 }
358
359 let mut options = File::options();
360 options.read(true).write(true).create(true).truncate(false);
361
362 #[cfg(unix)]
363 {
364 use std::os::unix::fs::OpenOptionsExt as _;
365 options.mode(0o600);
366 }
367
368 options.open(&lock_path).map_err(|err| {
369 Error::config(format!(
370 "Failed to open permissions lock file {}: {err}",
371 lock_path.display()
372 ))
373 })
374}
375
376fn lock_permissions_file(file: File, timeout: Duration) -> Result<PermissionsLockGuard> {
377 let start = Instant::now();
378 loop {
379 match FileExt::try_lock_exclusive(&file) {
380 Ok(true) => return Ok(PermissionsLockGuard { file }),
381 Ok(false) => {}
382 Err(err) => {
383 return Err(Error::config(format!(
384 "Failed to lock permissions file: {err}"
385 )));
386 }
387 }
388
389 if start.elapsed() >= timeout {
390 return Err(Error::config("Timed out waiting for permissions lock"));
391 }
392
393 std::thread::sleep(Duration::from_millis(50));
394 }
395}
396
397struct PermissionsLockGuard {
398 file: File,
399}
400
401impl Drop for PermissionsLockGuard {
402 fn drop(&mut self) {
403 let _ = FileExt::unlock(&self.file);
404 }
405}
406
407#[cfg(unix)]
408fn sync_permissions_parent_dir(path: &Path) -> std::io::Result<()> {
409 let Some(parent) = path.parent() else {
410 return Ok(());
411 };
412 if parent.as_os_str().is_empty() {
413 return Ok(());
414 }
415 File::open(parent)?.sync_all()
416}
417
418#[cfg(not(unix))]
419fn sync_permissions_parent_dir(_path: &Path) -> std::io::Result<()> {
420 Ok(())
421}
422
423fn now_iso8601() -> String {
424 let now = std::time::SystemTime::now();
427 let duration = now.duration_since(std::time::UNIX_EPOCH).unwrap_or({
428 std::time::Duration::ZERO
431 });
432 let secs = duration.as_secs();
433 let days = secs / 86400;
436 let time_of_day = secs % 86400;
437 let hours = time_of_day / 3600;
438 let minutes = (time_of_day % 3600) / 60;
439 let seconds = time_of_day % 60;
440
441 let (year, month, day) = days_to_ymd(days);
443 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
444}
445
446fn parse_expiry_timestamp(expires_at: &str) -> Option<DateTime<Utc>> {
447 DateTime::parse_from_rfc3339(expires_at)
448 .ok()
449 .map(|timestamp| timestamp.with_timezone(&Utc))
450}
451
452fn decision_is_active(decision: &PersistedDecision, now: DateTime<Utc>) -> bool {
453 decision.expires_at.as_deref().is_none_or(|expires_at| {
454 parse_expiry_timestamp(expires_at).is_some_and(|expiry| now <= expiry)
455 })
456}
457
458const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
461 let z = days.saturating_add(719_468);
464 let era = z / 146_097;
465 let era_days = era.saturating_mul(146_097);
466 let doe = z.saturating_sub(era_days);
467 let doe_div_1460 = doe / 1460;
468 let doe_div_36524 = doe / 36524;
469 let doe_div_146096 = doe / 146_096;
470 let yoe = doe
471 .saturating_sub(doe_div_1460)
472 .saturating_add(doe_div_36524)
473 .saturating_sub(doe_div_146096)
474 / 365;
475 let era_years = era.saturating_mul(400);
476 let y = yoe.saturating_add(era_years);
477 let yoe_times_365 = yoe.saturating_mul(365);
478 let yoe_div_4 = yoe / 4;
479 let yoe_div_100 = yoe / 100;
480 let days_in_year = yoe_times_365
481 .saturating_add(yoe_div_4)
482 .saturating_sub(yoe_div_100);
483 let doy = doe.saturating_sub(days_in_year);
484 let doy_times_5 = doy.saturating_mul(5);
485 let mp = doy_times_5.saturating_add(2) / 153;
486 let mp_times_153 = mp.saturating_mul(153);
487 let d = doy
488 .saturating_sub((mp_times_153.saturating_add(2)) / 5)
489 .saturating_add(1);
490 let m = if mp < 10 {
491 mp.saturating_add(3)
492 } else {
493 mp.saturating_sub(9)
494 };
495 let y = if m <= 2 { y.saturating_add(1) } else { y };
496 (y, m, d)
497}
498
499#[cfg(test)]
504mod tests {
505 use super::*;
506 use std::sync::{Arc, Barrier};
507
508 #[test]
509 fn roundtrip_empty() {
510 let dir = tempfile::tempdir().unwrap();
511 let path = dir.path().join("permissions.json");
512
513 let store = PermissionStore::open(&path).unwrap();
514 assert!(store.list().is_empty());
515
516 assert!(!path.exists());
518 }
519
520 #[test]
521 fn record_and_lookup() {
522 let dir = tempfile::tempdir().unwrap();
523 let path = dir.path().join("permissions.json");
524
525 let mut store = PermissionStore::open(&path).unwrap();
526 store.record("my-ext", "exec", true).unwrap();
527 store.record("my-ext", "env", false).unwrap();
528 store.record("other-ext", "http", true).unwrap();
529
530 assert_eq!(store.lookup("my-ext", "exec"), Some(true));
531 assert_eq!(store.lookup("my-ext", "env"), Some(false));
532 assert_eq!(store.lookup("other-ext", "http"), Some(true));
533 assert_eq!(store.lookup("unknown", "exec"), None);
534 assert_eq!(store.lookup("my-ext", "unknown"), None);
535
536 let store2 = PermissionStore::open(&path).unwrap();
538 assert_eq!(store2.lookup("my-ext", "exec"), Some(true));
539 assert_eq!(store2.lookup("my-ext", "env"), Some(false));
540 assert_eq!(store2.lookup("other-ext", "http"), Some(true));
541 }
542
543 #[test]
544 fn revoke_extension() {
545 let dir = tempfile::tempdir().unwrap();
546 let path = dir.path().join("permissions.json");
547
548 let mut store = PermissionStore::open(&path).unwrap();
549 store.record("my-ext", "exec", true).unwrap();
550 store.record("my-ext", "env", false).unwrap();
551 store.record("other-ext", "http", true).unwrap();
552
553 store.revoke_extension("my-ext").unwrap();
554
555 assert_eq!(store.lookup("my-ext", "exec"), None);
556 assert_eq!(store.lookup("my-ext", "env"), None);
557 assert_eq!(store.lookup("other-ext", "http"), Some(true));
558
559 let store2 = PermissionStore::open(&path).unwrap();
561 assert_eq!(store2.lookup("my-ext", "exec"), None);
562 }
563
564 #[test]
565 fn reset_all() {
566 let dir = tempfile::tempdir().unwrap();
567 let path = dir.path().join("permissions.json");
568
569 let mut store = PermissionStore::open(&path).unwrap();
570 store.record("a", "exec", true).unwrap();
571 store.record("b", "http", false).unwrap();
572 store.reset().unwrap();
573
574 assert!(store.list().is_empty());
575
576 let store2 = PermissionStore::open(&path).unwrap();
577 assert!(store2.list().is_empty());
578 }
579
580 #[test]
581 fn to_cache_map_filters_expired() {
582 let dir = tempfile::tempdir().unwrap();
583 let path = dir.path().join("permissions.json");
584
585 let mut store = PermissionStore::open(&path).unwrap();
586
587 store
589 .decisions
590 .entry("ext1".to_string())
591 .or_default()
592 .insert(
593 "exec".to_string(),
594 PersistedDecision {
595 capability: "exec".to_string(),
596 allow: true,
597 decided_at: "2026-01-01T00:00:00Z".to_string(),
598 expires_at: Some("2099-12-31T23:59:59Z".to_string()),
599 version_range: None,
600 },
601 );
602
603 store
605 .decisions
606 .entry("ext1".to_string())
607 .or_default()
608 .insert(
609 "env".to_string(),
610 PersistedDecision {
611 capability: "env".to_string(),
612 allow: false,
613 decided_at: "2020-01-01T00:00:00Z".to_string(),
614 expires_at: Some("2020-06-01T00:00:00Z".to_string()),
615 version_range: None,
616 },
617 );
618
619 let cache = store.to_cache_map();
620 assert_eq!(cache.get("ext1").and_then(|m| m.get("exec")), Some(&true));
621 assert_eq!(cache.get("ext1").and_then(|m| m.get("env")), None);
623 }
624
625 #[test]
626 fn overwrite_decision() {
627 let dir = tempfile::tempdir().unwrap();
628 let path = dir.path().join("permissions.json");
629
630 let mut store = PermissionStore::open(&path).unwrap();
631 store.record("ext", "exec", true).unwrap();
632 assert_eq!(store.lookup("ext", "exec"), Some(true));
633
634 store.record("ext", "exec", false).unwrap();
636 assert_eq!(store.lookup("ext", "exec"), Some(false));
637
638 let store2 = PermissionStore::open(&path).unwrap();
640 assert_eq!(store2.lookup("ext", "exec"), Some(false));
641 }
642
643 #[test]
644 fn version_range_stored() {
645 let dir = tempfile::tempdir().unwrap();
646 let path = dir.path().join("permissions.json");
647
648 let mut store = PermissionStore::open(&path).unwrap();
649 store
650 .record_with_version("ext", "exec", true, ">=1.0.0")
651 .unwrap();
652
653 let store2 = PermissionStore::open(&path).unwrap();
654 let dec = store2
655 .decisions
656 .get("ext")
657 .and_then(|m| m.get("exec"))
658 .unwrap();
659 assert_eq!(dec.version_range.as_deref(), Some(">=1.0.0"));
660 assert!(dec.allow);
661 }
662
663 #[test]
664 fn now_iso8601_format() {
665 let ts = now_iso8601();
666 assert_eq!(ts.len(), 20);
668 assert!(ts.ends_with('Z'));
669 assert_eq!(ts.as_bytes()[4], b'-');
670 assert_eq!(ts.as_bytes()[7], b'-');
671 assert_eq!(ts.as_bytes()[10], b'T');
672 assert_eq!(ts.as_bytes()[13], b':');
673 assert_eq!(ts.as_bytes()[16], b':');
674 }
675
676 #[test]
681 fn days_to_ymd_epoch() {
682 assert_eq!(days_to_ymd(0), (1970, 1, 1));
684 }
685
686 #[test]
687 fn days_to_ymd_known_dates() {
688 assert_eq!(days_to_ymd(10957), (2000, 1, 1));
690 assert_eq!(days_to_ymd(11016), (2000, 2, 29));
692 assert_eq!(days_to_ymd(11017), (2000, 3, 1));
694 assert_eq!(days_to_ymd(19723), (2024, 1, 1));
696 }
697
698 #[test]
699 fn days_to_ymd_dec_31() {
700 assert_eq!(days_to_ymd(364), (1970, 12, 31));
702 assert_eq!(days_to_ymd(365), (1971, 1, 1));
704 }
705
706 #[test]
707 fn days_to_ymd_leap_year_boundary() {
708 assert_eq!(days_to_ymd(789), (1972, 2, 29));
712 assert_eq!(days_to_ymd(790), (1972, 3, 1));
713 }
714
715 #[test]
716 fn days_to_ymd_far_future() {
717 assert_eq!(days_to_ymd(47_481), (2099, 12, 31));
719 assert_eq!(days_to_ymd(47_482), (2100, 1, 1));
720 }
721
722 #[test]
727 fn now_iso8601_lexicographic_order() {
728 let ts1 = now_iso8601();
729 let ts2 = now_iso8601();
730 assert!(ts2 >= ts1);
732 }
733
734 #[test]
735 fn now_iso8601_year_plausible() {
736 let ts = now_iso8601();
737 let year: u32 = ts[0..4].parse().unwrap();
738 assert!(year >= 2024);
739 assert!(year <= 2100);
740 }
741
742 #[test]
747 fn persisted_decision_serde_minimal() {
748 let dec = PersistedDecision {
750 capability: "exec".to_string(),
751 allow: true,
752 decided_at: "2026-01-15T10:30:00Z".to_string(),
753 expires_at: None,
754 version_range: None,
755 };
756 let json = serde_json::to_string(&dec).unwrap();
757 assert!(!json.contains("expires_at"));
758 assert!(!json.contains("version_range"));
759
760 let roundtrip: PersistedDecision = serde_json::from_str(&json).unwrap();
761 assert_eq!(roundtrip, dec);
762 }
763
764 #[test]
765 fn persisted_decision_serde_full() {
766 let dec = PersistedDecision {
767 capability: "http".to_string(),
768 allow: false,
769 decided_at: "2026-01-15T10:30:00Z".to_string(),
770 expires_at: Some("2026-06-15T10:30:00Z".to_string()),
771 version_range: Some(">=2.0.0".to_string()),
772 };
773 let json = serde_json::to_string(&dec).unwrap();
774 assert!(json.contains("expires_at"));
775 assert!(json.contains("version_range"));
776
777 let roundtrip: PersistedDecision = serde_json::from_str(&json).unwrap();
778 assert_eq!(roundtrip, dec);
779 }
780
781 #[test]
782 fn persisted_decision_deserialize_missing_optionals() {
783 let json = r#"{"capability":"exec","allow":true,"decided_at":"2026-01-01T00:00:00Z"}"#;
785 let dec: PersistedDecision = serde_json::from_str(json).unwrap();
786 assert_eq!(dec.capability, "exec");
787 assert!(dec.allow);
788 assert!(dec.expires_at.is_none());
789 assert!(dec.version_range.is_none());
790 }
791
792 #[test]
797 fn permissions_file_default_version() {
798 let file = PermissionsFile::default();
799 assert_eq!(file.version, CURRENT_VERSION);
800 assert!(file.decisions.is_empty());
801 }
802
803 #[test]
804 fn permissions_file_serde_roundtrip() {
805 let mut decisions = BTreeMap::new();
806 decisions.insert(
807 "ext-a".to_string(),
808 vec![PersistedDecision {
809 capability: "exec".to_string(),
810 allow: true,
811 decided_at: "2026-01-01T00:00:00Z".to_string(),
812 expires_at: None,
813 version_range: None,
814 }],
815 );
816 let file = PermissionsFile {
817 version: CURRENT_VERSION,
818 decisions,
819 };
820 let json = serde_json::to_string_pretty(&file).unwrap();
821 let roundtrip: PermissionsFile = serde_json::from_str(&json).unwrap();
822 assert_eq!(roundtrip.version, CURRENT_VERSION);
823 assert_eq!(roundtrip.decisions.len(), 1);
824 assert_eq!(roundtrip.decisions["ext-a"].len(), 1);
825 assert_eq!(roundtrip.decisions["ext-a"][0].capability, "exec");
826 }
827
828 #[test]
833 fn open_corrupt_file_returns_error() {
834 let dir = tempfile::tempdir().unwrap();
835 let path = dir.path().join("permissions.json");
836 std::fs::write(&path, "not valid json!!!").unwrap();
837
838 let result = PermissionStore::open(&path);
839 assert!(result.is_err());
840 let err_msg = format!("{}", result.unwrap_err());
841 assert!(err_msg.contains("parse"));
842 }
843
844 #[test]
845 fn open_empty_json_object_returns_error() {
846 let dir = tempfile::tempdir().unwrap();
848 let path = dir.path().join("permissions.json");
849 std::fs::write(&path, "{}").unwrap();
850
851 let result = PermissionStore::open(&path);
852 assert!(result.is_err());
853 }
854
855 #[test]
856 fn open_valid_empty_decisions() {
857 let dir = tempfile::tempdir().unwrap();
858 let path = dir.path().join("permissions.json");
859 std::fs::write(&path, r#"{"version":1,"decisions":{}}"#).unwrap();
860
861 let store = PermissionStore::open(&path).unwrap();
862 assert!(store.list().is_empty());
863 }
864
865 #[test]
866 fn open_unsupported_schema_version_returns_error() {
867 let dir = tempfile::tempdir().unwrap();
868 let path = dir.path().join("permissions.json");
869 std::fs::write(&path, r#"{"version":999,"decisions":{}}"#).unwrap();
870
871 let result = PermissionStore::open(&path);
872 assert!(result.is_err());
873 let err_msg = format!("{}", result.unwrap_err());
874 assert!(err_msg.contains("Unsupported permissions file schema version"));
875 }
876
877 #[test]
878 fn lookup_expired_decision_returns_none() {
879 let dir = tempfile::tempdir().unwrap();
880 let path = dir.path().join("permissions.json");
881 let mut store = PermissionStore::open(&path).unwrap();
882
883 store
885 .decisions
886 .entry("ext".to_string())
887 .or_default()
888 .insert(
889 "exec".to_string(),
890 PersistedDecision {
891 capability: "exec".to_string(),
892 allow: true,
893 decided_at: "2020-01-01T00:00:00Z".to_string(),
894 expires_at: Some("2020-06-01T00:00:00Z".to_string()),
895 version_range: None,
896 },
897 );
898
899 assert_eq!(store.lookup("ext", "exec"), None);
900 }
901
902 #[test]
903 fn lookup_future_expiry_returns_decision() {
904 let dir = tempfile::tempdir().unwrap();
905 let path = dir.path().join("permissions.json");
906 let mut store = PermissionStore::open(&path).unwrap();
907
908 store
909 .decisions
910 .entry("ext".to_string())
911 .or_default()
912 .insert(
913 "exec".to_string(),
914 PersistedDecision {
915 capability: "exec".to_string(),
916 allow: false,
917 decided_at: "2026-01-01T00:00:00Z".to_string(),
918 expires_at: Some("2099-12-31T23:59:59Z".to_string()),
919 version_range: None,
920 },
921 );
922
923 assert_eq!(store.lookup("ext", "exec"), Some(false));
924 }
925
926 #[test]
927 fn lookup_expiry_with_timezone_offset_uses_actual_timestamp() {
928 let decision = PersistedDecision {
929 capability: "exec".to_string(),
930 allow: true,
931 decided_at: "2026-01-01T00:00:00Z".to_string(),
932 expires_at: Some("2026-01-01T00:30:00+01:00".to_string()),
933 version_range: None,
934 };
935 let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
936 .unwrap()
937 .with_timezone(&Utc);
938
939 assert!(
940 !decision_is_active(&decision, now),
941 "offset expiry should be normalized before comparison"
942 );
943 }
944
945 #[test]
946 fn lookup_invalid_expiry_treated_as_absent() {
947 let dir = tempfile::tempdir().unwrap();
948 let path = dir.path().join("permissions.json");
949 let mut store = PermissionStore::open(&path).unwrap();
950
951 store
952 .decisions
953 .entry("ext".to_string())
954 .or_default()
955 .insert(
956 "exec".to_string(),
957 PersistedDecision {
958 capability: "exec".to_string(),
959 allow: true,
960 decided_at: "2026-01-01T00:00:00Z".to_string(),
961 expires_at: Some("not-a-timestamp".to_string()),
962 version_range: None,
963 },
964 );
965
966 assert_eq!(store.lookup("ext", "exec"), None);
967 let cache = store.to_cache_map();
968 assert_eq!(cache.get("ext").and_then(|caps| caps.get("exec")), None);
969 }
970
971 #[test]
972 fn lookup_no_expiry_returns_decision() {
973 let dir = tempfile::tempdir().unwrap();
974 let path = dir.path().join("permissions.json");
975 let mut store = PermissionStore::open(&path).unwrap();
976
977 store
978 .decisions
979 .entry("ext".to_string())
980 .or_default()
981 .insert(
982 "exec".to_string(),
983 PersistedDecision {
984 capability: "exec".to_string(),
985 allow: true,
986 decided_at: "2026-01-01T00:00:00Z".to_string(),
987 expires_at: None,
988 version_range: None,
989 },
990 );
991
992 assert_eq!(store.lookup("ext", "exec"), Some(true));
993 }
994
995 #[test]
996 fn record_creates_parent_directories() {
997 let dir = tempfile::tempdir().unwrap();
998 let path = dir
999 .path()
1000 .join("deep")
1001 .join("nested")
1002 .join("permissions.json");
1003
1004 let mut store = PermissionStore::open(&path).unwrap();
1005 store.record("ext", "exec", true).unwrap();
1006
1007 assert!(path.exists());
1008 let store2 = PermissionStore::open(&path).unwrap();
1009 assert_eq!(store2.lookup("ext", "exec"), Some(true));
1010 }
1011
1012 #[test]
1013 fn multiple_capabilities_per_extension() {
1014 let dir = tempfile::tempdir().unwrap();
1015 let path = dir.path().join("permissions.json");
1016 let mut store = PermissionStore::open(&path).unwrap();
1017
1018 store.record("ext", "exec", true).unwrap();
1019 store.record("ext", "http", false).unwrap();
1020 store.record("ext", "env", true).unwrap();
1021 store.record("ext", "fs", false).unwrap();
1022
1023 assert_eq!(store.lookup("ext", "exec"), Some(true));
1024 assert_eq!(store.lookup("ext", "http"), Some(false));
1025 assert_eq!(store.lookup("ext", "env"), Some(true));
1026 assert_eq!(store.lookup("ext", "fs"), Some(false));
1027
1028 let store2 = PermissionStore::open(&path).unwrap();
1030 assert_eq!(store2.lookup("ext", "exec"), Some(true));
1031 assert_eq!(store2.lookup("ext", "http"), Some(false));
1032 assert_eq!(store2.lookup("ext", "env"), Some(true));
1033 assert_eq!(store2.lookup("ext", "fs"), Some(false));
1034 }
1035
1036 #[test]
1037 fn record_with_version_stores_range() {
1038 let dir = tempfile::tempdir().unwrap();
1039 let path = dir.path().join("permissions.json");
1040 let mut store = PermissionStore::open(&path).unwrap();
1041
1042 store
1043 .record_with_version("ext", "exec", true, "^1.0.0")
1044 .unwrap();
1045 store
1046 .record_with_version("ext", "http", false, ">=2.0.0 <3.0.0")
1047 .unwrap();
1048
1049 let dec_exec = store.decisions["ext"].get("exec").unwrap();
1050 assert_eq!(dec_exec.version_range.as_deref(), Some("^1.0.0"));
1051 assert!(dec_exec.allow);
1052
1053 let dec_http = store.decisions["ext"].get("http").unwrap();
1054 assert_eq!(dec_http.version_range.as_deref(), Some(">=2.0.0 <3.0.0"));
1055 assert!(!dec_http.allow);
1056 }
1057
1058 #[test]
1059 fn record_with_version_overwrites_previous() {
1060 let dir = tempfile::tempdir().unwrap();
1061 let path = dir.path().join("permissions.json");
1062 let mut store = PermissionStore::open(&path).unwrap();
1063
1064 store
1065 .record_with_version("ext", "exec", true, "^1.0.0")
1066 .unwrap();
1067 store
1068 .record_with_version("ext", "exec", false, "^2.0.0")
1069 .unwrap();
1070
1071 let dec = store.decisions["ext"].get("exec").unwrap();
1072 assert_eq!(dec.version_range.as_deref(), Some("^2.0.0"));
1073 assert!(!dec.allow);
1074 }
1075
1076 #[test]
1077 fn list_returns_all_decisions() {
1078 let dir = tempfile::tempdir().unwrap();
1079 let path = dir.path().join("permissions.json");
1080 let mut store = PermissionStore::open(&path).unwrap();
1081
1082 store.record("ext-a", "exec", true).unwrap();
1083 store.record("ext-a", "http", false).unwrap();
1084 store.record("ext-b", "env", true).unwrap();
1085
1086 let all = store.list();
1087 assert_eq!(all.len(), 2);
1088 assert_eq!(all["ext-a"].len(), 2);
1089 assert_eq!(all["ext-b"].len(), 1);
1090 }
1091
1092 #[test]
1093 fn revoke_nonexistent_extension_is_noop() {
1094 let dir = tempfile::tempdir().unwrap();
1095 let path = dir.path().join("permissions.json");
1096 let mut store = PermissionStore::open(&path).unwrap();
1097
1098 store.record("ext", "exec", true).unwrap();
1099 store.revoke_extension("nonexistent").unwrap();
1101
1102 assert_eq!(store.lookup("ext", "exec"), Some(true));
1103 }
1104
1105 #[test]
1110 fn to_cache_map_all_expired_removes_extension() {
1111 let dir = tempfile::tempdir().unwrap();
1112 let path = dir.path().join("permissions.json");
1113 let mut store = PermissionStore::open(&path).unwrap();
1114
1115 store
1117 .decisions
1118 .entry("ext".to_string())
1119 .or_default()
1120 .insert(
1121 "exec".to_string(),
1122 PersistedDecision {
1123 capability: "exec".to_string(),
1124 allow: true,
1125 decided_at: "2020-01-01T00:00:00Z".to_string(),
1126 expires_at: Some("2020-06-01T00:00:00Z".to_string()),
1127 version_range: None,
1128 },
1129 );
1130 store
1131 .decisions
1132 .entry("ext".to_string())
1133 .or_default()
1134 .insert(
1135 "http".to_string(),
1136 PersistedDecision {
1137 capability: "http".to_string(),
1138 allow: false,
1139 decided_at: "2020-01-01T00:00:00Z".to_string(),
1140 expires_at: Some("2020-06-01T00:00:00Z".to_string()),
1141 version_range: None,
1142 },
1143 );
1144
1145 let cache = store.to_cache_map();
1146 assert!(!cache.contains_key("ext"));
1148 }
1149
1150 #[test]
1151 fn to_cache_map_no_expiry_always_included() {
1152 let dir = tempfile::tempdir().unwrap();
1153 let path = dir.path().join("permissions.json");
1154 let mut store = PermissionStore::open(&path).unwrap();
1155
1156 store
1157 .decisions
1158 .entry("ext".to_string())
1159 .or_default()
1160 .insert(
1161 "exec".to_string(),
1162 PersistedDecision {
1163 capability: "exec".to_string(),
1164 allow: true,
1165 decided_at: "2026-01-01T00:00:00Z".to_string(),
1166 expires_at: None,
1167 version_range: None,
1168 },
1169 );
1170
1171 let cache = store.to_cache_map();
1172 assert_eq!(cache.get("ext").and_then(|m| m.get("exec")), Some(&true));
1173 }
1174
1175 #[test]
1176 fn to_cache_map_multiple_extensions() {
1177 let dir = tempfile::tempdir().unwrap();
1178 let path = dir.path().join("permissions.json");
1179 let mut store = PermissionStore::open(&path).unwrap();
1180
1181 store.record("ext-a", "exec", true).unwrap();
1182 store.record("ext-a", "http", false).unwrap();
1183 store.record("ext-b", "env", true).unwrap();
1184
1185 let cache = store.to_cache_map();
1186 assert_eq!(cache.len(), 2);
1187 assert_eq!(cache.get("ext-a").and_then(|m| m.get("exec")), Some(&true));
1188 assert_eq!(cache.get("ext-a").and_then(|m| m.get("http")), Some(&false));
1189 assert_eq!(cache.get("ext-b").and_then(|m| m.get("env")), Some(&true));
1190 }
1191
1192 #[cfg(unix)]
1197 #[test]
1198 fn save_sets_file_permissions_0o600() {
1199 use std::os::unix::fs::PermissionsExt as _;
1200 let dir = tempfile::tempdir().unwrap();
1201 let path = dir.path().join("permissions.json");
1202
1203 let mut store = PermissionStore::open(&path).unwrap();
1204 store.record("ext", "exec", true).unwrap();
1205
1206 let metadata = std::fs::metadata(&path).unwrap();
1207 let mode = metadata.permissions().mode() & 0o777;
1208 assert_eq!(mode, 0o600);
1209 }
1210
1211 #[test]
1216 fn multiple_saves_and_reloads() {
1217 let dir = tempfile::tempdir().unwrap();
1218 let path = dir.path().join("permissions.json");
1219
1220 {
1222 let mut store = PermissionStore::open(&path).unwrap();
1223 store.record("ext-a", "exec", true).unwrap();
1224 store.record("ext-b", "http", false).unwrap();
1225 }
1226
1227 {
1229 let mut store = PermissionStore::open(&path).unwrap();
1230 store.record("ext-a", "exec", false).unwrap(); store.record("ext-c", "env", true).unwrap(); }
1233
1234 {
1236 let store = PermissionStore::open(&path).unwrap();
1237 assert_eq!(store.lookup("ext-a", "exec"), Some(false)); assert_eq!(store.lookup("ext-b", "http"), Some(false)); assert_eq!(store.lookup("ext-c", "env"), Some(true)); }
1241 }
1242
1243 #[test]
1244 fn save_serializes_extensions_and_capabilities_stably() {
1245 let dir = tempfile::tempdir().unwrap();
1246 let path = dir.path().join("permissions.json");
1247 let mut store = PermissionStore::open(&path).unwrap();
1248
1249 store.record("ext-b", "env", true).unwrap();
1250 store.record("ext-a", "http", false).unwrap();
1251 store.record("ext-a", "exec", true).unwrap();
1252
1253 let raw = std::fs::read_to_string(&path).unwrap();
1254 let ext_a = raw.find("\"ext-a\"").unwrap();
1255 let ext_b = raw.find("\"ext-b\"").unwrap();
1256 let exec = raw.find("\"capability\": \"exec\"").unwrap();
1257 let http = raw.find("\"capability\": \"http\"").unwrap();
1258 let env = raw.find("\"capability\": \"env\"").unwrap();
1259
1260 assert!(
1261 ext_a < ext_b,
1262 "extension ids should serialize in sorted order"
1263 );
1264 assert!(exec < http, "capabilities should serialize in sorted order");
1265 assert!(
1266 http < env,
1267 "later extensions should appear after earlier ones"
1268 );
1269 }
1270
1271 #[test]
1272 fn reset_then_record_works() {
1273 let dir = tempfile::tempdir().unwrap();
1274 let path = dir.path().join("permissions.json");
1275
1276 let mut store = PermissionStore::open(&path).unwrap();
1277 store.record("ext", "exec", true).unwrap();
1278 store.reset().unwrap();
1279 store.record("ext", "http", false).unwrap();
1280
1281 assert_eq!(store.lookup("ext", "exec"), None);
1282 assert_eq!(store.lookup("ext", "http"), Some(false));
1283
1284 let store2 = PermissionStore::open(&path).unwrap();
1285 assert_eq!(store2.lookup("ext", "exec"), None);
1286 assert_eq!(store2.lookup("ext", "http"), Some(false));
1287 }
1288
1289 #[test]
1290 fn decided_at_is_recent() {
1291 let dir = tempfile::tempdir().unwrap();
1292 let path = dir.path().join("permissions.json");
1293
1294 let mut store = PermissionStore::open(&path).unwrap();
1295 store.record("ext", "exec", true).unwrap();
1296
1297 let dec = &store.decisions["ext"]["exec"];
1298 let year: u32 = dec.decided_at[0..4].parse().unwrap();
1300 assert!(year >= 2024);
1301 }
1302
1303 #[test]
1304 fn concurrent_records_preserve_all_decisions() {
1305 let dir = tempfile::tempdir().unwrap();
1306 let path = dir.path().join("permissions.json");
1307 let workers = 12;
1308 let barrier = Arc::new(Barrier::new(workers));
1309 let mut handles = Vec::new();
1310
1311 for idx in 0..workers {
1312 let barrier = Arc::clone(&barrier);
1313 let path = path.clone();
1314 handles.push(std::thread::spawn(move || {
1315 let mut store = PermissionStore::open(&path).unwrap();
1316 barrier.wait();
1317 store
1318 .record(&format!("ext-{idx}"), "exec", idx % 2 == 0)
1319 .unwrap();
1320 }));
1321 }
1322
1323 for handle in handles {
1324 handle.join().unwrap();
1325 }
1326
1327 let store = PermissionStore::open(&path).unwrap();
1328 for idx in 0..workers {
1329 assert_eq!(
1330 store.lookup(&format!("ext-{idx}"), "exec"),
1331 Some(idx % 2 == 0)
1332 );
1333 }
1334 }
1335
1336 mod proptest_permissions {
1337 use super::*;
1338 use proptest::prelude::*;
1339
1340 proptest! {
1341 #[test]
1343 fn days_to_ymd_valid_ranges(days in 0..100_000u64) {
1344 let (y, m, d) = days_to_ymd(days);
1345 assert!(y >= 1970, "year {y} too small for days={days}");
1346 assert!((1..=12).contains(&m), "month {m} out of range");
1347 assert!((1..=31).contains(&d), "day {d} out of range");
1348 }
1349
1350 #[test]
1352 fn days_to_ymd_epoch(_dummy in 0..1u8) {
1353 let (y, m, d) = days_to_ymd(0);
1354 assert_eq!((y, m, d), (1970, 1, 1));
1355 }
1356
1357 #[test]
1359 fn days_to_ymd_consecutive(days in 0..99_999u64) {
1360 let (y1, m1, d1) = days_to_ymd(days);
1361 let (y2, m2, d2) = days_to_ymd(days + 1);
1362 if d2 == d1 + 1 && m2 == m1 && y2 == y1 {
1364 } else if d2 == 1 {
1366 assert!(m2 != m1 || y2 != y1);
1368 } else {
1369 assert!(false, "unexpected day sequence: {y1}-{m1}-{d1} -> {y2}-{m2}-{d2}");
1370 }
1371 }
1372
1373 #[test]
1375 fn now_iso8601_format(_dummy in 0..1u8) {
1376 let ts = now_iso8601();
1377 assert_eq!(ts.len(), 20, "expected YYYY-MM-DDThh:mm:ssZ, got {ts}");
1378 assert!(ts.ends_with('Z'));
1379 assert_eq!(&ts[4..5], "-");
1380 assert_eq!(&ts[7..8], "-");
1381 assert_eq!(&ts[10..11], "T");
1382 assert_eq!(&ts[13..14], ":");
1383 assert_eq!(&ts[16..17], ":");
1384 }
1385
1386 #[test]
1388 fn decision_serde_roundtrip(
1389 cap in "[a-z]{1,10}",
1390 allow in proptest::bool::ANY,
1391 has_expiry in proptest::bool::ANY,
1392 has_range in proptest::bool::ANY
1393 ) {
1394 let dec = PersistedDecision {
1395 capability: cap,
1396 allow,
1397 decided_at: "2025-01-01T00:00:00Z".to_string(),
1398 expires_at: if has_expiry { Some("2030-01-01T00:00:00Z".to_string()) } else { None },
1399 version_range: if has_range { Some(">=1.0.0".to_string()) } else { None },
1400 };
1401 let json = serde_json::to_string(&dec).unwrap();
1402 let back: PersistedDecision = serde_json::from_str(&json).unwrap();
1403 assert_eq!(dec, back);
1404 }
1405
1406 #[test]
1408 fn record_lookup_roundtrip(
1409 ext_id in "[a-z]{1,8}",
1410 cap in "[a-z]{1,8}",
1411 allow in proptest::bool::ANY
1412 ) {
1413 let dir = tempfile::tempdir().unwrap();
1414 let path = dir.path().join("perm.json");
1415 let mut store = PermissionStore::open(&path).unwrap();
1416 store.record(&ext_id, &cap, allow).unwrap();
1417 assert_eq!(store.lookup(&ext_id, &cap), Some(allow));
1418 }
1419
1420 #[test]
1422 fn lookup_unknown_extension(ext in "[a-z]{1,10}", cap in "[a-z]{1,5}") {
1423 let dir = tempfile::tempdir().unwrap();
1424 let path = dir.path().join("perm.json");
1425 let store = PermissionStore::open(&path).unwrap();
1426 assert_eq!(store.lookup(&ext, &cap), None);
1427 }
1428
1429 #[test]
1431 fn record_overwrites(ext in "[a-z]{1,8}", cap in "[a-z]{1,8}") {
1432 let dir = tempfile::tempdir().unwrap();
1433 let path = dir.path().join("perm.json");
1434 let mut store = PermissionStore::open(&path).unwrap();
1435 store.record(&ext, &cap, true).unwrap();
1436 assert_eq!(store.lookup(&ext, &cap), Some(true));
1437 store.record(&ext, &cap, false).unwrap();
1438 assert_eq!(store.lookup(&ext, &cap), Some(false));
1439 }
1440
1441 #[test]
1443 fn revoke_removes_all(ext in "[a-z]{1,8}", cap1 in "[a-z]{1,5}", cap2 in "[a-z]{1,5}") {
1444 let dir = tempfile::tempdir().unwrap();
1445 let path = dir.path().join("perm.json");
1446 let mut store = PermissionStore::open(&path).unwrap();
1447 store.record(&ext, &cap1, true).unwrap();
1448 store.record(&ext, &cap2, false).unwrap();
1449 store.revoke_extension(&ext).unwrap();
1450 assert_eq!(store.lookup(&ext, &cap1), None);
1451 assert_eq!(store.lookup(&ext, &cap2), None);
1452 }
1453
1454 #[test]
1456 fn days_to_ymd_year_boundary(_dummy in 0..1u8) {
1457 let (y, m, d) = days_to_ymd(365);
1458 assert_eq!(y, 1971);
1459 assert_eq!(m, 1);
1460 assert_eq!(d, 1);
1461 }
1462
1463 #[test]
1465 fn days_to_ymd_leap_day_2000(_dummy in 0..1u8) {
1466 let (y, m, d) = days_to_ymd(11016);
1469 assert_eq!((y, m, d), (2000, 2, 29));
1470 }
1471 }
1472 }
1473}