Skip to main content

pi/
permissions.rs

1//! Persistent storage for extension capability decisions.
2//!
3//! When a user chooses "Allow Always" or "Deny Always" for an extension
4//! capability prompt, the decision is recorded here so it survives across
5//! sessions.  Decisions are keyed by `(extension_id, capability)` and
6//! optionally scoped to a version range.
7
8use 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
16/// On-disk schema version.
17const CURRENT_VERSION: u32 = 1;
18
19// ---------------------------------------------------------------------------
20// Types
21// ---------------------------------------------------------------------------
22
23/// A persisted capability decision.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct PersistedDecision {
26    /// The capability that was prompted (e.g. `exec`, `http`).
27    pub capability: String,
28
29    /// `true` = allowed, `false` = denied.
30    pub allow: bool,
31
32    /// ISO-8601 timestamp when the decision was made.
33    pub decided_at: String,
34
35    /// Optional ISO-8601 expiry.  `None` means the decision never expires.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub expires_at: Option<String>,
38
39    /// Optional semver range string (e.g. `>=1.0.0`).
40    /// If the extension's version no longer satisfies this range the decision
41    /// is treated as absent (user gets re-prompted).
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub version_range: Option<String>,
44}
45
46/// Root structure serialized to disk.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48struct PermissionsFile {
49    version: u32,
50    /// `extension_id` → list of decisions.
51    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// ---------------------------------------------------------------------------
64// Store
65// ---------------------------------------------------------------------------
66
67/// In-memory mirror of the on-disk permissions file with load/save helpers.
68#[derive(Debug, Clone)]
69pub struct PermissionStore {
70    path: PathBuf,
71    /// `extension_id` → `capability` → decision.
72    decisions: HashMap<String, HashMap<String, PersistedDecision>>,
73}
74
75impl PermissionStore {
76    /// Open (or create) the permissions store at the default global path.
77    pub fn open_default() -> Result<Self> {
78        Self::open(&Config::permissions_path())
79    }
80
81    /// Open (or create) the permissions store at a specific path.
82    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            // Convert Vec<PersistedDecision> → HashMap keyed by capability.
97            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    /// Look up a persisted decision for `(extension_id, capability)`.
118    ///
119    /// Returns `Some(true)` for allow, `Some(false)` for deny, `None` if no
120    /// decision is stored (or the stored decision has expired).
121    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        // Check expiry.
126        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    /// Record a decision and persist to disk.
137    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    /// Record a decision with a version range constraint.
155    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    /// Remove all decisions for a specific extension.
179    pub fn revoke_extension(&mut self, extension_id: &str) -> Result<()> {
180        self.decisions.remove(extension_id);
181        self.save()
182    }
183
184    /// Remove all persisted decisions.
185    pub fn reset(&mut self) -> Result<()> {
186        self.decisions.clear();
187        self.save()
188    }
189
190    /// List all persisted decisions grouped by extension.
191    pub const fn list(&self) -> &HashMap<String, HashMap<String, PersistedDecision>> {
192        &self.decisions
193    }
194
195    /// Seed the in-memory cache of an `ExtensionManager`-style
196    /// `HashMap<String, HashMap<String, bool>>` from persisted decisions.
197    ///
198    /// Only non-expired entries are included.
199    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    /// Retrieve the full decision cache (including version ranges) for
216    /// runtime enforcement.
217    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    // -----------------------------------------------------------------------
234    // Internal
235    // -----------------------------------------------------------------------
236
237    /// Atomic write to disk following the same pattern as `config.rs`.
238    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        // Convert internal HashMap → Vec for stable serialization.
245        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
284// ---------------------------------------------------------------------------
285// Helpers
286// ---------------------------------------------------------------------------
287
288fn now_iso8601() -> String {
289    // Use wall-clock time.  We don't need sub-second precision for expiry
290    // comparisons, but include it for diagnostics.
291    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    // Simple ISO-8601 without pulling in chrono: YYYY-MM-DDThh:mm:ssZ
297    // (good enough for lexicographic comparison).
298    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    // Convert days since epoch to date using a basic algorithm.
305    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
309/// Convert days since Unix epoch to (year, month, day).
310const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
311    // Algorithm from Howard Hinnant's `chrono`-compatible date library.
312    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// ---------------------------------------------------------------------------
326// Tests
327// ---------------------------------------------------------------------------
328
329#[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        // File should not exist until a record is made.
342        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        // Reload from disk.
362        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        // Persists to disk.
385        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        // Insert a non-expired decision directly.
413        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        // Insert an expired decision.
429        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        // Expired entry should be absent.
447        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        // Overwrite with deny.
460        store.record("ext", "exec", false).unwrap();
461        assert_eq!(store.lookup("ext", "exec"), Some(false));
462
463        // Persists.
464        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        // Basic format check: YYYY-MM-DDThh:mm:ssZ
492        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    // -----------------------------------------------------------------------
502    // days_to_ymd tests
503    // -----------------------------------------------------------------------
504
505    #[test]
506    fn days_to_ymd_epoch() {
507        // Day 0 = 1970-01-01
508        assert_eq!(days_to_ymd(0), (1970, 1, 1));
509    }
510
511    #[test]
512    fn days_to_ymd_known_dates() {
513        // 2000-01-01 = day 10957
514        assert_eq!(days_to_ymd(10957), (2000, 1, 1));
515        // 2000-02-29 (leap year) = day 10957 + 31 (Jan) + 28 (Feb 1..28) = 11016
516        assert_eq!(days_to_ymd(11016), (2000, 2, 29));
517        // 2000-03-01 = day 11017
518        assert_eq!(days_to_ymd(11017), (2000, 3, 1));
519        // 2024-01-01 = day 19723
520        assert_eq!(days_to_ymd(19723), (2024, 1, 1));
521    }
522
523    #[test]
524    fn days_to_ymd_dec_31() {
525        // 1970-12-31 = day 364
526        assert_eq!(days_to_ymd(364), (1970, 12, 31));
527        // 1971-01-01 = day 365
528        assert_eq!(days_to_ymd(365), (1971, 1, 1));
529    }
530
531    #[test]
532    fn days_to_ymd_leap_year_boundary() {
533        // 1972 is a leap year: Feb 29 = day 789 (730 days for 1970-1971 + 31 + 28)
534        // 1970: 365, 1971: 365 = 730
535        // Jan 1972: 31 → 761, Feb 1-28: 28 → 789 = Feb 29
536        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        // 2099-12-31 = day 47481, 2100-01-01 = day 47482
543        assert_eq!(days_to_ymd(47_481), (2099, 12, 31));
544        assert_eq!(days_to_ymd(47_482), (2100, 1, 1));
545    }
546
547    // -----------------------------------------------------------------------
548    // now_iso8601 additional tests
549    // -----------------------------------------------------------------------
550
551    #[test]
552    fn now_iso8601_lexicographic_order() {
553        let ts1 = now_iso8601();
554        let ts2 = now_iso8601();
555        // Second call should be >= first (same second or later)
556        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    // -----------------------------------------------------------------------
568    // PersistedDecision serde tests
569    // -----------------------------------------------------------------------
570
571    #[test]
572    fn persisted_decision_serde_minimal() {
573        // Optional fields should be omitted when None
574        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        // JSON without optional fields should deserialize fine
609        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    // -----------------------------------------------------------------------
618    // PermissionsFile serde tests
619    // -----------------------------------------------------------------------
620
621    #[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    // -----------------------------------------------------------------------
654    // PermissionStore edge cases
655    // -----------------------------------------------------------------------
656
657    #[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        // An empty object {} is missing required fields
672        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        // Insert a decision that expired in the past
697        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        // All persist
797        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        // Revoking a non-existent extension should not fail
868        store.revoke_extension("nonexistent").unwrap();
869
870        assert_eq!(store.lookup("ext", "exec"), Some(true));
871    }
872
873    // -----------------------------------------------------------------------
874    // to_cache_map additional scenarios
875    // -----------------------------------------------------------------------
876
877    #[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        // All decisions for this extension are expired
884        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        // Extension should not appear at all since all entries are expired
915        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    // -----------------------------------------------------------------------
961    // File permissions test (Unix)
962    // -----------------------------------------------------------------------
963
964    #[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    // -----------------------------------------------------------------------
980    // Disk persistence roundtrip tests
981    // -----------------------------------------------------------------------
982
983    #[test]
984    fn multiple_saves_and_reloads() {
985        let dir = tempfile::tempdir().unwrap();
986        let path = dir.path().join("permissions.json");
987
988        // First session
989        {
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        // Second session: modify and add
996        {
997            let mut store = PermissionStore::open(&path).unwrap();
998            store.record("ext-a", "exec", false).unwrap(); // overwrite
999            store.record("ext-c", "env", true).unwrap(); // new
1000        }
1001
1002        // Third session: verify all state
1003        {
1004            let store = PermissionStore::open(&path).unwrap();
1005            assert_eq!(store.lookup("ext-a", "exec"), Some(false)); // overwritten
1006            assert_eq!(store.lookup("ext-b", "http"), Some(false)); // unchanged
1007            assert_eq!(store.lookup("ext-c", "env"), Some(true)); // new
1008        }
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        // decided_at should be a recent timestamp (year >= 2024)
1039        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            /// `days_to_ymd` produces valid month/day ranges.
1049            #[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            /// `days_to_ymd(0)` is 1970-01-01.
1058            #[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            /// Consecutive days increment the day or roll the month/year.
1065            #[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                // Either same date with day+1, or month/year rollover
1070                if d2 == d1 + 1 && m2 == m1 && y2 == y1 {
1071                    // Normal day increment
1072                } else if d2 == 1 {
1073                    // Day rolled over to 1 — month or year changed
1074                    assert!(m2 != m1 || y2 != y1);
1075                } else {
1076                    panic!("unexpected day sequence: {y1}-{m1}-{d1} -> {y2}-{m2}-{d2}");
1077                }
1078            }
1079
1080            /// `now_iso8601` produces valid ISO-8601 format.
1081            #[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            /// `PersistedDecision` serde roundtrip preserves all fields.
1094            #[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            /// Record then lookup returns the correct allow/deny value.
1114            #[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            /// Lookup for unknown extension returns None.
1128            #[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            /// Record overwrites previous decision for same (ext, cap).
1137            #[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            /// Revoke removes all decisions for an extension.
1149            #[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            /// Days 365 is in 1971 (non-leap year 1970).
1162            #[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            /// Leap day 2000 (day 10957 from epoch) is Feb 29.
1171            #[test]
1172            fn days_to_ymd_leap_day_2000(_dummy in 0..1u8) {
1173                // 2000-02-29 is day 11016 from epoch
1174                // 1970-01-01 + 11016 days
1175                let (y, m, d) = days_to_ymd(11016);
1176                assert_eq!((y, m, d), (2000, 2, 29));
1177            }
1178        }
1179    }
1180}