1use crate::config::Config;
9use crate::error::{Error, Result};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::io::Write as _;
13use std::path::{Path, PathBuf};
14use tempfile::NamedTempFile;
15
16const CURRENT_VERSION: u32 = 1;
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct PersistedDecision {
26 pub capability: String,
28
29 pub allow: bool,
31
32 pub decided_at: String,
34
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub expires_at: Option<String>,
38
39 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub version_range: Option<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48struct PermissionsFile {
49 version: u32,
50 decisions: HashMap<String, Vec<PersistedDecision>>,
52}
53
54impl Default for PermissionsFile {
55 fn default() -> Self {
56 Self {
57 version: CURRENT_VERSION,
58 decisions: HashMap::new(),
59 }
60 }
61}
62
63#[derive(Debug, Clone)]
69pub struct PermissionStore {
70 path: PathBuf,
71 decisions: HashMap<String, HashMap<String, PersistedDecision>>,
73}
74
75impl PermissionStore {
76 pub fn open_default() -> Result<Self> {
78 Self::open(&Config::permissions_path())
79 }
80
81 pub fn open(path: &Path) -> Result<Self> {
83 let decisions = if path.exists() {
84 let raw = std::fs::read_to_string(path).map_err(|e| {
85 Error::config(format!(
86 "Failed to read permissions file {}: {e}",
87 path.display()
88 ))
89 })?;
90 let file: PermissionsFile = serde_json::from_str(&raw).map_err(|e| {
91 Error::config(format!(
92 "Failed to parse permissions file {}: {e}",
93 path.display()
94 ))
95 })?;
96 file.decisions
98 .into_iter()
99 .map(|(ext_id, decs)| {
100 let by_cap: HashMap<String, PersistedDecision> = decs
101 .into_iter()
102 .map(|d| (d.capability.clone(), d))
103 .collect();
104 (ext_id, by_cap)
105 })
106 .collect()
107 } else {
108 HashMap::new()
109 };
110
111 Ok(Self {
112 path: path.to_path_buf(),
113 decisions,
114 })
115 }
116
117 pub fn lookup(&self, extension_id: &str, capability: &str) -> Option<bool> {
122 let by_cap = self.decisions.get(extension_id)?;
123 let dec = by_cap.get(capability)?;
124
125 if let Some(ref exp) = dec.expires_at {
127 let now = now_iso8601();
128 if now > *exp {
129 return None;
130 }
131 }
132
133 Some(dec.allow)
134 }
135
136 pub fn record(&mut self, extension_id: &str, capability: &str, allow: bool) -> Result<()> {
138 let decision = PersistedDecision {
139 capability: capability.to_string(),
140 allow,
141 decided_at: now_iso8601(),
142 expires_at: None,
143 version_range: None,
144 };
145
146 self.decisions
147 .entry(extension_id.to_string())
148 .or_default()
149 .insert(capability.to_string(), decision);
150
151 self.save()
152 }
153
154 pub fn record_with_version(
156 &mut self,
157 extension_id: &str,
158 capability: &str,
159 allow: bool,
160 version_range: &str,
161 ) -> Result<()> {
162 let decision = PersistedDecision {
163 capability: capability.to_string(),
164 allow,
165 decided_at: now_iso8601(),
166 expires_at: None,
167 version_range: Some(version_range.to_string()),
168 };
169
170 self.decisions
171 .entry(extension_id.to_string())
172 .or_default()
173 .insert(capability.to_string(), decision);
174
175 self.save()
176 }
177
178 pub fn revoke_extension(&mut self, extension_id: &str) -> Result<()> {
180 self.decisions.remove(extension_id);
181 self.save()
182 }
183
184 pub fn reset(&mut self) -> Result<()> {
186 self.decisions.clear();
187 self.save()
188 }
189
190 pub const fn list(&self) -> &HashMap<String, HashMap<String, PersistedDecision>> {
192 &self.decisions
193 }
194
195 pub fn to_cache_map(&self) -> HashMap<String, HashMap<String, bool>> {
200 let now = now_iso8601();
201 self.decisions
202 .iter()
203 .map(|(ext_id, by_cap)| {
204 let filtered: HashMap<String, bool> = by_cap
205 .iter()
206 .filter(|(_, dec)| dec.expires_at.as_ref().is_none_or(|exp| now <= *exp))
207 .map(|(cap, dec)| (cap.clone(), dec.allow))
208 .collect();
209 (ext_id.clone(), filtered)
210 })
211 .filter(|(_, m)| !m.is_empty())
212 .collect()
213 }
214
215 pub fn to_decision_cache(&self) -> HashMap<String, HashMap<String, PersistedDecision>> {
218 let now = now_iso8601();
219 self.decisions
220 .iter()
221 .map(|(ext_id, by_cap)| {
222 let filtered: HashMap<String, PersistedDecision> = by_cap
223 .iter()
224 .filter(|(_, dec)| dec.expires_at.as_ref().is_none_or(|exp| now <= *exp))
225 .map(|(cap, dec)| (cap.clone(), dec.clone()))
226 .collect();
227 (ext_id.clone(), filtered)
228 })
229 .filter(|(_, m)| !m.is_empty())
230 .collect()
231 }
232
233 fn save(&self) -> Result<()> {
239 let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
240 if !parent.as_os_str().is_empty() {
241 std::fs::create_dir_all(parent)?;
242 }
243
244 let file = PermissionsFile {
246 version: CURRENT_VERSION,
247 decisions: self
248 .decisions
249 .iter()
250 .map(|(ext_id, by_cap)| {
251 let decs: Vec<PersistedDecision> = by_cap.values().cloned().collect();
252 (ext_id.clone(), decs)
253 })
254 .collect(),
255 };
256
257 let mut contents = serde_json::to_string_pretty(&file)?;
258 contents.push('\n');
259
260 let mut tmp = NamedTempFile::new_in(parent)?;
261
262 #[cfg(unix)]
263 {
264 use std::os::unix::fs::PermissionsExt as _;
265 let perms = std::fs::Permissions::from_mode(0o600);
266 tmp.as_file().set_permissions(perms)?;
267 }
268
269 tmp.write_all(contents.as_bytes())?;
270 tmp.as_file().sync_all()?;
271
272 tmp.persist(&self.path).map_err(|err| {
273 Error::config(format!(
274 "Failed to persist permissions file to {}: {}",
275 self.path.display(),
276 err.error
277 ))
278 })?;
279
280 Ok(())
281 }
282}
283
284fn now_iso8601() -> String {
289 let now = std::time::SystemTime::now();
292 let duration = now
293 .duration_since(std::time::UNIX_EPOCH)
294 .unwrap_or_default();
295 let secs = duration.as_secs();
296 let days = secs / 86400;
299 let time_of_day = secs % 86400;
300 let hours = time_of_day / 3600;
301 let minutes = (time_of_day % 3600) / 60;
302 let seconds = time_of_day % 60;
303
304 let (year, month, day) = days_to_ymd(days);
306 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
307}
308
309const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
311 let z = days + 719_468;
313 let era = z / 146_097;
314 let doe = z - era * 146_097;
315 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
316 let y = yoe + era * 400;
317 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
318 let mp = (5 * doy + 2) / 153;
319 let d = doy - (153 * mp + 2) / 5 + 1;
320 let m = if mp < 10 { mp + 3 } else { mp - 9 };
321 let y = if m <= 2 { y + 1 } else { y };
322 (y, m, d)
323}
324
325#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn roundtrip_empty() {
335 let dir = tempfile::tempdir().unwrap();
336 let path = dir.path().join("permissions.json");
337
338 let store = PermissionStore::open(&path).unwrap();
339 assert!(store.list().is_empty());
340
341 assert!(!path.exists());
343 }
344
345 #[test]
346 fn record_and_lookup() {
347 let dir = tempfile::tempdir().unwrap();
348 let path = dir.path().join("permissions.json");
349
350 let mut store = PermissionStore::open(&path).unwrap();
351 store.record("my-ext", "exec", true).unwrap();
352 store.record("my-ext", "env", false).unwrap();
353 store.record("other-ext", "http", true).unwrap();
354
355 assert_eq!(store.lookup("my-ext", "exec"), Some(true));
356 assert_eq!(store.lookup("my-ext", "env"), Some(false));
357 assert_eq!(store.lookup("other-ext", "http"), Some(true));
358 assert_eq!(store.lookup("unknown", "exec"), None);
359 assert_eq!(store.lookup("my-ext", "unknown"), None);
360
361 let store2 = PermissionStore::open(&path).unwrap();
363 assert_eq!(store2.lookup("my-ext", "exec"), Some(true));
364 assert_eq!(store2.lookup("my-ext", "env"), Some(false));
365 assert_eq!(store2.lookup("other-ext", "http"), Some(true));
366 }
367
368 #[test]
369 fn revoke_extension() {
370 let dir = tempfile::tempdir().unwrap();
371 let path = dir.path().join("permissions.json");
372
373 let mut store = PermissionStore::open(&path).unwrap();
374 store.record("my-ext", "exec", true).unwrap();
375 store.record("my-ext", "env", false).unwrap();
376 store.record("other-ext", "http", true).unwrap();
377
378 store.revoke_extension("my-ext").unwrap();
379
380 assert_eq!(store.lookup("my-ext", "exec"), None);
381 assert_eq!(store.lookup("my-ext", "env"), None);
382 assert_eq!(store.lookup("other-ext", "http"), Some(true));
383
384 let store2 = PermissionStore::open(&path).unwrap();
386 assert_eq!(store2.lookup("my-ext", "exec"), None);
387 }
388
389 #[test]
390 fn reset_all() {
391 let dir = tempfile::tempdir().unwrap();
392 let path = dir.path().join("permissions.json");
393
394 let mut store = PermissionStore::open(&path).unwrap();
395 store.record("a", "exec", true).unwrap();
396 store.record("b", "http", false).unwrap();
397 store.reset().unwrap();
398
399 assert!(store.list().is_empty());
400
401 let store2 = PermissionStore::open(&path).unwrap();
402 assert!(store2.list().is_empty());
403 }
404
405 #[test]
406 fn to_cache_map_filters_expired() {
407 let dir = tempfile::tempdir().unwrap();
408 let path = dir.path().join("permissions.json");
409
410 let mut store = PermissionStore::open(&path).unwrap();
411
412 store
414 .decisions
415 .entry("ext1".to_string())
416 .or_default()
417 .insert(
418 "exec".to_string(),
419 PersistedDecision {
420 capability: "exec".to_string(),
421 allow: true,
422 decided_at: "2026-01-01T00:00:00Z".to_string(),
423 expires_at: Some("2099-12-31T23:59:59Z".to_string()),
424 version_range: None,
425 },
426 );
427
428 store
430 .decisions
431 .entry("ext1".to_string())
432 .or_default()
433 .insert(
434 "env".to_string(),
435 PersistedDecision {
436 capability: "env".to_string(),
437 allow: false,
438 decided_at: "2020-01-01T00:00:00Z".to_string(),
439 expires_at: Some("2020-06-01T00:00:00Z".to_string()),
440 version_range: None,
441 },
442 );
443
444 let cache = store.to_cache_map();
445 assert_eq!(cache.get("ext1").and_then(|m| m.get("exec")), Some(&true));
446 assert_eq!(cache.get("ext1").and_then(|m| m.get("env")), None);
448 }
449
450 #[test]
451 fn overwrite_decision() {
452 let dir = tempfile::tempdir().unwrap();
453 let path = dir.path().join("permissions.json");
454
455 let mut store = PermissionStore::open(&path).unwrap();
456 store.record("ext", "exec", true).unwrap();
457 assert_eq!(store.lookup("ext", "exec"), Some(true));
458
459 store.record("ext", "exec", false).unwrap();
461 assert_eq!(store.lookup("ext", "exec"), Some(false));
462
463 let store2 = PermissionStore::open(&path).unwrap();
465 assert_eq!(store2.lookup("ext", "exec"), Some(false));
466 }
467
468 #[test]
469 fn version_range_stored() {
470 let dir = tempfile::tempdir().unwrap();
471 let path = dir.path().join("permissions.json");
472
473 let mut store = PermissionStore::open(&path).unwrap();
474 store
475 .record_with_version("ext", "exec", true, ">=1.0.0")
476 .unwrap();
477
478 let store2 = PermissionStore::open(&path).unwrap();
479 let dec = store2
480 .decisions
481 .get("ext")
482 .and_then(|m| m.get("exec"))
483 .unwrap();
484 assert_eq!(dec.version_range.as_deref(), Some(">=1.0.0"));
485 assert!(dec.allow);
486 }
487
488 #[test]
489 fn now_iso8601_format() {
490 let ts = now_iso8601();
491 assert_eq!(ts.len(), 20);
493 assert!(ts.ends_with('Z'));
494 assert_eq!(ts.as_bytes()[4], b'-');
495 assert_eq!(ts.as_bytes()[7], b'-');
496 assert_eq!(ts.as_bytes()[10], b'T');
497 assert_eq!(ts.as_bytes()[13], b':');
498 assert_eq!(ts.as_bytes()[16], b':');
499 }
500
501 #[test]
506 fn days_to_ymd_epoch() {
507 assert_eq!(days_to_ymd(0), (1970, 1, 1));
509 }
510
511 #[test]
512 fn days_to_ymd_known_dates() {
513 assert_eq!(days_to_ymd(10957), (2000, 1, 1));
515 assert_eq!(days_to_ymd(11016), (2000, 2, 29));
517 assert_eq!(days_to_ymd(11017), (2000, 3, 1));
519 assert_eq!(days_to_ymd(19723), (2024, 1, 1));
521 }
522
523 #[test]
524 fn days_to_ymd_dec_31() {
525 assert_eq!(days_to_ymd(364), (1970, 12, 31));
527 assert_eq!(days_to_ymd(365), (1971, 1, 1));
529 }
530
531 #[test]
532 fn days_to_ymd_leap_year_boundary() {
533 assert_eq!(days_to_ymd(789), (1972, 2, 29));
537 assert_eq!(days_to_ymd(790), (1972, 3, 1));
538 }
539
540 #[test]
541 fn days_to_ymd_far_future() {
542 assert_eq!(days_to_ymd(47_481), (2099, 12, 31));
544 assert_eq!(days_to_ymd(47_482), (2100, 1, 1));
545 }
546
547 #[test]
552 fn now_iso8601_lexicographic_order() {
553 let ts1 = now_iso8601();
554 let ts2 = now_iso8601();
555 assert!(ts2 >= ts1);
557 }
558
559 #[test]
560 fn now_iso8601_year_plausible() {
561 let ts = now_iso8601();
562 let year: u32 = ts[0..4].parse().unwrap();
563 assert!(year >= 2024);
564 assert!(year <= 2100);
565 }
566
567 #[test]
572 fn persisted_decision_serde_minimal() {
573 let dec = PersistedDecision {
575 capability: "exec".to_string(),
576 allow: true,
577 decided_at: "2026-01-15T10:30:00Z".to_string(),
578 expires_at: None,
579 version_range: None,
580 };
581 let json = serde_json::to_string(&dec).unwrap();
582 assert!(!json.contains("expires_at"));
583 assert!(!json.contains("version_range"));
584
585 let roundtrip: PersistedDecision = serde_json::from_str(&json).unwrap();
586 assert_eq!(roundtrip, dec);
587 }
588
589 #[test]
590 fn persisted_decision_serde_full() {
591 let dec = PersistedDecision {
592 capability: "http".to_string(),
593 allow: false,
594 decided_at: "2026-01-15T10:30:00Z".to_string(),
595 expires_at: Some("2026-06-15T10:30:00Z".to_string()),
596 version_range: Some(">=2.0.0".to_string()),
597 };
598 let json = serde_json::to_string(&dec).unwrap();
599 assert!(json.contains("expires_at"));
600 assert!(json.contains("version_range"));
601
602 let roundtrip: PersistedDecision = serde_json::from_str(&json).unwrap();
603 assert_eq!(roundtrip, dec);
604 }
605
606 #[test]
607 fn persisted_decision_deserialize_missing_optionals() {
608 let json = r#"{"capability":"exec","allow":true,"decided_at":"2026-01-01T00:00:00Z"}"#;
610 let dec: PersistedDecision = serde_json::from_str(json).unwrap();
611 assert_eq!(dec.capability, "exec");
612 assert!(dec.allow);
613 assert!(dec.expires_at.is_none());
614 assert!(dec.version_range.is_none());
615 }
616
617 #[test]
622 fn permissions_file_default_version() {
623 let file = PermissionsFile::default();
624 assert_eq!(file.version, CURRENT_VERSION);
625 assert!(file.decisions.is_empty());
626 }
627
628 #[test]
629 fn permissions_file_serde_roundtrip() {
630 let mut decisions = HashMap::new();
631 decisions.insert(
632 "ext-a".to_string(),
633 vec![PersistedDecision {
634 capability: "exec".to_string(),
635 allow: true,
636 decided_at: "2026-01-01T00:00:00Z".to_string(),
637 expires_at: None,
638 version_range: None,
639 }],
640 );
641 let file = PermissionsFile {
642 version: CURRENT_VERSION,
643 decisions,
644 };
645 let json = serde_json::to_string_pretty(&file).unwrap();
646 let roundtrip: PermissionsFile = serde_json::from_str(&json).unwrap();
647 assert_eq!(roundtrip.version, CURRENT_VERSION);
648 assert_eq!(roundtrip.decisions.len(), 1);
649 assert_eq!(roundtrip.decisions["ext-a"].len(), 1);
650 assert_eq!(roundtrip.decisions["ext-a"][0].capability, "exec");
651 }
652
653 #[test]
658 fn open_corrupt_file_returns_error() {
659 let dir = tempfile::tempdir().unwrap();
660 let path = dir.path().join("permissions.json");
661 std::fs::write(&path, "not valid json!!!").unwrap();
662
663 let result = PermissionStore::open(&path);
664 assert!(result.is_err());
665 let err_msg = format!("{}", result.unwrap_err());
666 assert!(err_msg.contains("parse"));
667 }
668
669 #[test]
670 fn open_empty_json_object_returns_error() {
671 let dir = tempfile::tempdir().unwrap();
673 let path = dir.path().join("permissions.json");
674 std::fs::write(&path, "{}").unwrap();
675
676 let result = PermissionStore::open(&path);
677 assert!(result.is_err());
678 }
679
680 #[test]
681 fn open_valid_empty_decisions() {
682 let dir = tempfile::tempdir().unwrap();
683 let path = dir.path().join("permissions.json");
684 std::fs::write(&path, r#"{"version":1,"decisions":{}}"#).unwrap();
685
686 let store = PermissionStore::open(&path).unwrap();
687 assert!(store.list().is_empty());
688 }
689
690 #[test]
691 fn lookup_expired_decision_returns_none() {
692 let dir = tempfile::tempdir().unwrap();
693 let path = dir.path().join("permissions.json");
694 let mut store = PermissionStore::open(&path).unwrap();
695
696 store
698 .decisions
699 .entry("ext".to_string())
700 .or_default()
701 .insert(
702 "exec".to_string(),
703 PersistedDecision {
704 capability: "exec".to_string(),
705 allow: true,
706 decided_at: "2020-01-01T00:00:00Z".to_string(),
707 expires_at: Some("2020-06-01T00:00:00Z".to_string()),
708 version_range: None,
709 },
710 );
711
712 assert_eq!(store.lookup("ext", "exec"), None);
713 }
714
715 #[test]
716 fn lookup_future_expiry_returns_decision() {
717 let dir = tempfile::tempdir().unwrap();
718 let path = dir.path().join("permissions.json");
719 let mut store = PermissionStore::open(&path).unwrap();
720
721 store
722 .decisions
723 .entry("ext".to_string())
724 .or_default()
725 .insert(
726 "exec".to_string(),
727 PersistedDecision {
728 capability: "exec".to_string(),
729 allow: false,
730 decided_at: "2026-01-01T00:00:00Z".to_string(),
731 expires_at: Some("2099-12-31T23:59:59Z".to_string()),
732 version_range: None,
733 },
734 );
735
736 assert_eq!(store.lookup("ext", "exec"), Some(false));
737 }
738
739 #[test]
740 fn lookup_no_expiry_returns_decision() {
741 let dir = tempfile::tempdir().unwrap();
742 let path = dir.path().join("permissions.json");
743 let mut store = PermissionStore::open(&path).unwrap();
744
745 store
746 .decisions
747 .entry("ext".to_string())
748 .or_default()
749 .insert(
750 "exec".to_string(),
751 PersistedDecision {
752 capability: "exec".to_string(),
753 allow: true,
754 decided_at: "2026-01-01T00:00:00Z".to_string(),
755 expires_at: None,
756 version_range: None,
757 },
758 );
759
760 assert_eq!(store.lookup("ext", "exec"), Some(true));
761 }
762
763 #[test]
764 fn record_creates_parent_directories() {
765 let dir = tempfile::tempdir().unwrap();
766 let path = dir
767 .path()
768 .join("deep")
769 .join("nested")
770 .join("permissions.json");
771
772 let mut store = PermissionStore::open(&path).unwrap();
773 store.record("ext", "exec", true).unwrap();
774
775 assert!(path.exists());
776 let store2 = PermissionStore::open(&path).unwrap();
777 assert_eq!(store2.lookup("ext", "exec"), Some(true));
778 }
779
780 #[test]
781 fn multiple_capabilities_per_extension() {
782 let dir = tempfile::tempdir().unwrap();
783 let path = dir.path().join("permissions.json");
784 let mut store = PermissionStore::open(&path).unwrap();
785
786 store.record("ext", "exec", true).unwrap();
787 store.record("ext", "http", false).unwrap();
788 store.record("ext", "env", true).unwrap();
789 store.record("ext", "fs", false).unwrap();
790
791 assert_eq!(store.lookup("ext", "exec"), Some(true));
792 assert_eq!(store.lookup("ext", "http"), Some(false));
793 assert_eq!(store.lookup("ext", "env"), Some(true));
794 assert_eq!(store.lookup("ext", "fs"), Some(false));
795
796 let store2 = PermissionStore::open(&path).unwrap();
798 assert_eq!(store2.lookup("ext", "exec"), Some(true));
799 assert_eq!(store2.lookup("ext", "http"), Some(false));
800 assert_eq!(store2.lookup("ext", "env"), Some(true));
801 assert_eq!(store2.lookup("ext", "fs"), Some(false));
802 }
803
804 #[test]
805 fn record_with_version_stores_range() {
806 let dir = tempfile::tempdir().unwrap();
807 let path = dir.path().join("permissions.json");
808 let mut store = PermissionStore::open(&path).unwrap();
809
810 store
811 .record_with_version("ext", "exec", true, "^1.0.0")
812 .unwrap();
813 store
814 .record_with_version("ext", "http", false, ">=2.0.0 <3.0.0")
815 .unwrap();
816
817 let dec_exec = store.decisions["ext"].get("exec").unwrap();
818 assert_eq!(dec_exec.version_range.as_deref(), Some("^1.0.0"));
819 assert!(dec_exec.allow);
820
821 let dec_http = store.decisions["ext"].get("http").unwrap();
822 assert_eq!(dec_http.version_range.as_deref(), Some(">=2.0.0 <3.0.0"));
823 assert!(!dec_http.allow);
824 }
825
826 #[test]
827 fn record_with_version_overwrites_previous() {
828 let dir = tempfile::tempdir().unwrap();
829 let path = dir.path().join("permissions.json");
830 let mut store = PermissionStore::open(&path).unwrap();
831
832 store
833 .record_with_version("ext", "exec", true, "^1.0.0")
834 .unwrap();
835 store
836 .record_with_version("ext", "exec", false, "^2.0.0")
837 .unwrap();
838
839 let dec = store.decisions["ext"].get("exec").unwrap();
840 assert_eq!(dec.version_range.as_deref(), Some("^2.0.0"));
841 assert!(!dec.allow);
842 }
843
844 #[test]
845 fn list_returns_all_decisions() {
846 let dir = tempfile::tempdir().unwrap();
847 let path = dir.path().join("permissions.json");
848 let mut store = PermissionStore::open(&path).unwrap();
849
850 store.record("ext-a", "exec", true).unwrap();
851 store.record("ext-a", "http", false).unwrap();
852 store.record("ext-b", "env", true).unwrap();
853
854 let all = store.list();
855 assert_eq!(all.len(), 2);
856 assert_eq!(all["ext-a"].len(), 2);
857 assert_eq!(all["ext-b"].len(), 1);
858 }
859
860 #[test]
861 fn revoke_nonexistent_extension_is_noop() {
862 let dir = tempfile::tempdir().unwrap();
863 let path = dir.path().join("permissions.json");
864 let mut store = PermissionStore::open(&path).unwrap();
865
866 store.record("ext", "exec", true).unwrap();
867 store.revoke_extension("nonexistent").unwrap();
869
870 assert_eq!(store.lookup("ext", "exec"), Some(true));
871 }
872
873 #[test]
878 fn to_cache_map_all_expired_removes_extension() {
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 store
899 .decisions
900 .entry("ext".to_string())
901 .or_default()
902 .insert(
903 "http".to_string(),
904 PersistedDecision {
905 capability: "http".to_string(),
906 allow: false,
907 decided_at: "2020-01-01T00:00:00Z".to_string(),
908 expires_at: Some("2020-06-01T00:00:00Z".to_string()),
909 version_range: None,
910 },
911 );
912
913 let cache = store.to_cache_map();
914 assert!(!cache.contains_key("ext"));
916 }
917
918 #[test]
919 fn to_cache_map_no_expiry_always_included() {
920 let dir = tempfile::tempdir().unwrap();
921 let path = dir.path().join("permissions.json");
922 let mut store = PermissionStore::open(&path).unwrap();
923
924 store
925 .decisions
926 .entry("ext".to_string())
927 .or_default()
928 .insert(
929 "exec".to_string(),
930 PersistedDecision {
931 capability: "exec".to_string(),
932 allow: true,
933 decided_at: "2026-01-01T00:00:00Z".to_string(),
934 expires_at: None,
935 version_range: None,
936 },
937 );
938
939 let cache = store.to_cache_map();
940 assert_eq!(cache.get("ext").and_then(|m| m.get("exec")), Some(&true));
941 }
942
943 #[test]
944 fn to_cache_map_multiple_extensions() {
945 let dir = tempfile::tempdir().unwrap();
946 let path = dir.path().join("permissions.json");
947 let mut store = PermissionStore::open(&path).unwrap();
948
949 store.record("ext-a", "exec", true).unwrap();
950 store.record("ext-a", "http", false).unwrap();
951 store.record("ext-b", "env", true).unwrap();
952
953 let cache = store.to_cache_map();
954 assert_eq!(cache.len(), 2);
955 assert_eq!(cache.get("ext-a").and_then(|m| m.get("exec")), Some(&true));
956 assert_eq!(cache.get("ext-a").and_then(|m| m.get("http")), Some(&false));
957 assert_eq!(cache.get("ext-b").and_then(|m| m.get("env")), Some(&true));
958 }
959
960 #[cfg(unix)]
965 #[test]
966 fn save_sets_file_permissions_0o600() {
967 use std::os::unix::fs::PermissionsExt as _;
968 let dir = tempfile::tempdir().unwrap();
969 let path = dir.path().join("permissions.json");
970
971 let mut store = PermissionStore::open(&path).unwrap();
972 store.record("ext", "exec", true).unwrap();
973
974 let metadata = std::fs::metadata(&path).unwrap();
975 let mode = metadata.permissions().mode() & 0o777;
976 assert_eq!(mode, 0o600);
977 }
978
979 #[test]
984 fn multiple_saves_and_reloads() {
985 let dir = tempfile::tempdir().unwrap();
986 let path = dir.path().join("permissions.json");
987
988 {
990 let mut store = PermissionStore::open(&path).unwrap();
991 store.record("ext-a", "exec", true).unwrap();
992 store.record("ext-b", "http", false).unwrap();
993 }
994
995 {
997 let mut store = PermissionStore::open(&path).unwrap();
998 store.record("ext-a", "exec", false).unwrap(); store.record("ext-c", "env", true).unwrap(); }
1001
1002 {
1004 let store = PermissionStore::open(&path).unwrap();
1005 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)); }
1009 }
1010
1011 #[test]
1012 fn reset_then_record_works() {
1013 let dir = tempfile::tempdir().unwrap();
1014 let path = dir.path().join("permissions.json");
1015
1016 let mut store = PermissionStore::open(&path).unwrap();
1017 store.record("ext", "exec", true).unwrap();
1018 store.reset().unwrap();
1019 store.record("ext", "http", false).unwrap();
1020
1021 assert_eq!(store.lookup("ext", "exec"), None);
1022 assert_eq!(store.lookup("ext", "http"), Some(false));
1023
1024 let store2 = PermissionStore::open(&path).unwrap();
1025 assert_eq!(store2.lookup("ext", "exec"), None);
1026 assert_eq!(store2.lookup("ext", "http"), Some(false));
1027 }
1028
1029 #[test]
1030 fn decided_at_is_recent() {
1031 let dir = tempfile::tempdir().unwrap();
1032 let path = dir.path().join("permissions.json");
1033
1034 let mut store = PermissionStore::open(&path).unwrap();
1035 store.record("ext", "exec", true).unwrap();
1036
1037 let dec = &store.decisions["ext"]["exec"];
1038 let year: u32 = dec.decided_at[0..4].parse().unwrap();
1040 assert!(year >= 2024);
1041 }
1042
1043 mod proptest_permissions {
1044 use super::*;
1045 use proptest::prelude::*;
1046
1047 proptest! {
1048 #[test]
1050 fn days_to_ymd_valid_ranges(days in 0..100_000u64) {
1051 let (y, m, d) = days_to_ymd(days);
1052 assert!(y >= 1970, "year {y} too small for days={days}");
1053 assert!((1..=12).contains(&m), "month {m} out of range");
1054 assert!((1..=31).contains(&d), "day {d} out of range");
1055 }
1056
1057 #[test]
1059 fn days_to_ymd_epoch(_dummy in 0..1u8) {
1060 let (y, m, d) = days_to_ymd(0);
1061 assert_eq!((y, m, d), (1970, 1, 1));
1062 }
1063
1064 #[test]
1066 fn days_to_ymd_consecutive(days in 0..99_999u64) {
1067 let (y1, m1, d1) = days_to_ymd(days);
1068 let (y2, m2, d2) = days_to_ymd(days + 1);
1069 if d2 == d1 + 1 && m2 == m1 && y2 == y1 {
1071 } else if d2 == 1 {
1073 assert!(m2 != m1 || y2 != y1);
1075 } else {
1076 panic!("unexpected day sequence: {y1}-{m1}-{d1} -> {y2}-{m2}-{d2}");
1077 }
1078 }
1079
1080 #[test]
1082 fn now_iso8601_format(_dummy in 0..1u8) {
1083 let ts = now_iso8601();
1084 assert_eq!(ts.len(), 20, "expected YYYY-MM-DDThh:mm:ssZ, got {ts}");
1085 assert!(ts.ends_with('Z'));
1086 assert_eq!(&ts[4..5], "-");
1087 assert_eq!(&ts[7..8], "-");
1088 assert_eq!(&ts[10..11], "T");
1089 assert_eq!(&ts[13..14], ":");
1090 assert_eq!(&ts[16..17], ":");
1091 }
1092
1093 #[test]
1095 fn decision_serde_roundtrip(
1096 cap in "[a-z]{1,10}",
1097 allow in proptest::bool::ANY,
1098 has_expiry in proptest::bool::ANY,
1099 has_range in proptest::bool::ANY
1100 ) {
1101 let dec = PersistedDecision {
1102 capability: cap,
1103 allow,
1104 decided_at: "2025-01-01T00:00:00Z".to_string(),
1105 expires_at: if has_expiry { Some("2030-01-01T00:00:00Z".to_string()) } else { None },
1106 version_range: if has_range { Some(">=1.0.0".to_string()) } else { None },
1107 };
1108 let json = serde_json::to_string(&dec).unwrap();
1109 let back: PersistedDecision = serde_json::from_str(&json).unwrap();
1110 assert_eq!(dec, back);
1111 }
1112
1113 #[test]
1115 fn record_lookup_roundtrip(
1116 ext_id in "[a-z]{1,8}",
1117 cap in "[a-z]{1,8}",
1118 allow in proptest::bool::ANY
1119 ) {
1120 let dir = tempfile::tempdir().unwrap();
1121 let path = dir.path().join("perm.json");
1122 let mut store = PermissionStore::open(&path).unwrap();
1123 store.record(&ext_id, &cap, allow).unwrap();
1124 assert_eq!(store.lookup(&ext_id, &cap), Some(allow));
1125 }
1126
1127 #[test]
1129 fn lookup_unknown_extension(ext in "[a-z]{1,10}", cap in "[a-z]{1,5}") {
1130 let dir = tempfile::tempdir().unwrap();
1131 let path = dir.path().join("perm.json");
1132 let store = PermissionStore::open(&path).unwrap();
1133 assert_eq!(store.lookup(&ext, &cap), None);
1134 }
1135
1136 #[test]
1138 fn record_overwrites(ext in "[a-z]{1,8}", cap in "[a-z]{1,8}") {
1139 let dir = tempfile::tempdir().unwrap();
1140 let path = dir.path().join("perm.json");
1141 let mut store = PermissionStore::open(&path).unwrap();
1142 store.record(&ext, &cap, true).unwrap();
1143 assert_eq!(store.lookup(&ext, &cap), Some(true));
1144 store.record(&ext, &cap, false).unwrap();
1145 assert_eq!(store.lookup(&ext, &cap), Some(false));
1146 }
1147
1148 #[test]
1150 fn revoke_removes_all(ext in "[a-z]{1,8}", cap1 in "[a-z]{1,5}", cap2 in "[a-z]{1,5}") {
1151 let dir = tempfile::tempdir().unwrap();
1152 let path = dir.path().join("perm.json");
1153 let mut store = PermissionStore::open(&path).unwrap();
1154 store.record(&ext, &cap1, true).unwrap();
1155 store.record(&ext, &cap2, false).unwrap();
1156 store.revoke_extension(&ext).unwrap();
1157 assert_eq!(store.lookup(&ext, &cap1), None);
1158 assert_eq!(store.lookup(&ext, &cap2), None);
1159 }
1160
1161 #[test]
1163 fn days_to_ymd_year_boundary(_dummy in 0..1u8) {
1164 let (y, m, d) = days_to_ymd(365);
1165 assert_eq!(y, 1971);
1166 assert_eq!(m, 1);
1167 assert_eq!(d, 1);
1168 }
1169
1170 #[test]
1172 fn days_to_ymd_leap_day_2000(_dummy in 0..1u8) {
1173 let (y, m, d) = days_to_ymd(11016);
1176 assert_eq!((y, m, d), (2000, 2, 29));
1177 }
1178 }
1179 }
1180}