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 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
21/// On-disk schema version.
22const CURRENT_VERSION: u32 = 1;
23
24// ---------------------------------------------------------------------------
25// Types
26// ---------------------------------------------------------------------------
27
28/// A persisted capability decision.
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct PersistedDecision {
31    /// The capability that was prompted (e.g. `exec`, `http`).
32    pub capability: String,
33
34    /// `true` = allowed, `false` = denied.
35    pub allow: bool,
36
37    /// ISO-8601 timestamp when the decision was made.
38    pub decided_at: String,
39
40    /// Optional ISO-8601 expiry.  `None` means the decision never expires.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub expires_at: Option<String>,
43
44    /// Optional semver range string (e.g. `>=1.0.0`).
45    /// If the extension's version no longer satisfies this range the decision
46    /// is treated as absent (user gets re-prompted).
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub version_range: Option<String>,
49}
50
51/// Root structure serialized to disk.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53struct PermissionsFile {
54    version: u32,
55    /// `extension_id` → list of decisions.
56    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// ---------------------------------------------------------------------------
69// Store
70// ---------------------------------------------------------------------------
71
72/// In-memory mirror of the on-disk permissions file with load/save helpers.
73#[derive(Debug, Clone)]
74pub struct PermissionStore {
75    path: PathBuf,
76    /// `extension_id` → `capability` → decision.
77    decisions: HashMap<String, HashMap<String, PersistedDecision>>,
78}
79
80impl PermissionStore {
81    /// Open (or create) the permissions store at the default global path.
82    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    /// Open (or create) the permissions store at a specific path.
94    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    /// Look up a persisted decision for `(extension_id, capability)`.
104    ///
105    /// Returns `Some(true)` for allow, `Some(false)` for deny, `None` if no
106    /// decision is stored (or the stored decision has expired).
107    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    /// Record a decision and persist to disk.
119    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    /// Record a decision with a version range constraint.
137    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    /// Remove all decisions for a specific extension.
161    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    /// Remove all persisted decisions.
168    pub fn reset(&mut self) -> Result<()> {
169        self.update_persisted_decisions(HashMap::clear)
170    }
171
172    /// List all persisted decisions grouped by extension.
173    pub const fn list(&self) -> &HashMap<String, HashMap<String, PersistedDecision>> {
174        &self.decisions
175    }
176
177    /// Seed the in-memory cache of an `ExtensionManager`-style
178    /// `HashMap<String, HashMap<String, bool>>` from persisted decisions.
179    ///
180    /// Only non-expired entries are included.
181    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    /// Retrieve the full decision cache (including version ranges) for
198    /// runtime enforcement.
199    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    // -----------------------------------------------------------------------
216    // Internal
217    // -----------------------------------------------------------------------
218
219    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    /// Atomic write to disk following the same pattern as `config.rs`.
235    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        // Convert internal HashMap → ordered on-disk structure for stable serialization.
242        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
290// ---------------------------------------------------------------------------
291// Helpers
292// ---------------------------------------------------------------------------
293
294fn 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    // Use wall-clock time.  We don't need sub-second precision for expiry
425    // comparisons, but include it for diagnostics.
426    let now = std::time::SystemTime::now();
427    let duration = now.duration_since(std::time::UNIX_EPOCH).unwrap_or({
428        // Clock is before UNIX_EPOCH - use epoch as fallback to prevent
429        // timestamp manipulation attacks on permission expiry
430        std::time::Duration::ZERO
431    });
432    let secs = duration.as_secs();
433    // Simple ISO-8601 without pulling in chrono: YYYY-MM-DDThh:mm:ssZ
434    // (good enough for lexicographic comparison).
435    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    // Convert days since epoch to date using a basic algorithm.
442    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
458/// Convert days since Unix epoch to (year, month, day).
459/// Uses saturating arithmetic to prevent overflow attacks.
460const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
461    // Algorithm from Howard Hinnant's `chrono`-compatible date library.
462    // Use saturating arithmetic to prevent overflow with malicious inputs.
463    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// ---------------------------------------------------------------------------
500// Tests
501// ---------------------------------------------------------------------------
502
503#[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        // File should not exist until a record is made.
517        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        // Reload from disk.
537        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        // Persists to disk.
560        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        // Insert a non-expired decision directly.
588        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        // Insert an expired decision.
604        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        // Expired entry should be absent.
622        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        // Overwrite with deny.
635        store.record("ext", "exec", false).unwrap();
636        assert_eq!(store.lookup("ext", "exec"), Some(false));
637
638        // Persists.
639        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        // Basic format check: YYYY-MM-DDThh:mm:ssZ
667        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    // -----------------------------------------------------------------------
677    // days_to_ymd tests
678    // -----------------------------------------------------------------------
679
680    #[test]
681    fn days_to_ymd_epoch() {
682        // Day 0 = 1970-01-01
683        assert_eq!(days_to_ymd(0), (1970, 1, 1));
684    }
685
686    #[test]
687    fn days_to_ymd_known_dates() {
688        // 2000-01-01 = day 10957
689        assert_eq!(days_to_ymd(10957), (2000, 1, 1));
690        // 2000-02-29 (leap year) = day 10957 + 31 (Jan) + 28 (Feb 1..28) = 11016
691        assert_eq!(days_to_ymd(11016), (2000, 2, 29));
692        // 2000-03-01 = day 11017
693        assert_eq!(days_to_ymd(11017), (2000, 3, 1));
694        // 2024-01-01 = day 19723
695        assert_eq!(days_to_ymd(19723), (2024, 1, 1));
696    }
697
698    #[test]
699    fn days_to_ymd_dec_31() {
700        // 1970-12-31 = day 364
701        assert_eq!(days_to_ymd(364), (1970, 12, 31));
702        // 1971-01-01 = day 365
703        assert_eq!(days_to_ymd(365), (1971, 1, 1));
704    }
705
706    #[test]
707    fn days_to_ymd_leap_year_boundary() {
708        // 1972 is a leap year: Feb 29 = day 789 (730 days for 1970-1971 + 31 + 28)
709        // 1970: 365, 1971: 365 = 730
710        // Jan 1972: 31 → 761, Feb 1-28: 28 → 789 = Feb 29
711        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        // 2099-12-31 = day 47481, 2100-01-01 = day 47482
718        assert_eq!(days_to_ymd(47_481), (2099, 12, 31));
719        assert_eq!(days_to_ymd(47_482), (2100, 1, 1));
720    }
721
722    // -----------------------------------------------------------------------
723    // now_iso8601 additional tests
724    // -----------------------------------------------------------------------
725
726    #[test]
727    fn now_iso8601_lexicographic_order() {
728        let ts1 = now_iso8601();
729        let ts2 = now_iso8601();
730        // Second call should be >= first (same second or later)
731        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    // -----------------------------------------------------------------------
743    // PersistedDecision serde tests
744    // -----------------------------------------------------------------------
745
746    #[test]
747    fn persisted_decision_serde_minimal() {
748        // Optional fields should be omitted when None
749        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        // JSON without optional fields should deserialize fine
784        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    // -----------------------------------------------------------------------
793    // PermissionsFile serde tests
794    // -----------------------------------------------------------------------
795
796    #[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    // -----------------------------------------------------------------------
829    // PermissionStore edge cases
830    // -----------------------------------------------------------------------
831
832    #[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        // An empty object {} is missing required fields
847        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        // Insert a decision that expired in the past
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
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        // All persist
1029        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        // Revoking a non-existent extension should not fail
1100        store.revoke_extension("nonexistent").unwrap();
1101
1102        assert_eq!(store.lookup("ext", "exec"), Some(true));
1103    }
1104
1105    // -----------------------------------------------------------------------
1106    // to_cache_map additional scenarios
1107    // -----------------------------------------------------------------------
1108
1109    #[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        // All decisions for this extension are expired
1116        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        // Extension should not appear at all since all entries are expired
1147        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    // -----------------------------------------------------------------------
1193    // File permissions test (Unix)
1194    // -----------------------------------------------------------------------
1195
1196    #[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    // -----------------------------------------------------------------------
1212    // Disk persistence roundtrip tests
1213    // -----------------------------------------------------------------------
1214
1215    #[test]
1216    fn multiple_saves_and_reloads() {
1217        let dir = tempfile::tempdir().unwrap();
1218        let path = dir.path().join("permissions.json");
1219
1220        // First session
1221        {
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        // Second session: modify and add
1228        {
1229            let mut store = PermissionStore::open(&path).unwrap();
1230            store.record("ext-a", "exec", false).unwrap(); // overwrite
1231            store.record("ext-c", "env", true).unwrap(); // new
1232        }
1233
1234        // Third session: verify all state
1235        {
1236            let store = PermissionStore::open(&path).unwrap();
1237            assert_eq!(store.lookup("ext-a", "exec"), Some(false)); // overwritten
1238            assert_eq!(store.lookup("ext-b", "http"), Some(false)); // unchanged
1239            assert_eq!(store.lookup("ext-c", "env"), Some(true)); // new
1240        }
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        // decided_at should be a recent timestamp (year >= 2024)
1299        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            /// `days_to_ymd` produces valid month/day ranges.
1342            #[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            /// `days_to_ymd(0)` is 1970-01-01.
1351            #[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            /// Consecutive days increment the day or roll the month/year.
1358            #[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                // Either same date with day+1, or month/year rollover
1363                if d2 == d1 + 1 && m2 == m1 && y2 == y1 {
1364                    // Normal day increment
1365                } else if d2 == 1 {
1366                    // Day rolled over to 1 — month or year changed
1367                    assert!(m2 != m1 || y2 != y1);
1368                } else {
1369                    assert!(false, "unexpected day sequence: {y1}-{m1}-{d1} -> {y2}-{m2}-{d2}");
1370                }
1371            }
1372
1373            /// `now_iso8601` produces valid ISO-8601 format.
1374            #[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            /// `PersistedDecision` serde roundtrip preserves all fields.
1387            #[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            /// Record then lookup returns the correct allow/deny value.
1407            #[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            /// Lookup for unknown extension returns None.
1421            #[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            /// Record overwrites previous decision for same (ext, cap).
1430            #[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            /// Revoke removes all decisions for an extension.
1442            #[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            /// Days 365 is in 1971 (non-leap year 1970).
1455            #[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            /// Leap day 2000 (day 10957 from epoch) is Feb 29.
1464            #[test]
1465            fn days_to_ymd_leap_day_2000(_dummy in 0..1u8) {
1466                // 2000-02-29 is day 11016 from epoch
1467                // 1970-01-01 + 11016 days
1468                let (y, m, d) = days_to_ymd(11016);
1469                assert_eq!((y, m, d), (2000, 2, 29));
1470            }
1471        }
1472    }
1473}