use crate::config::Config;
use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
const CURRENT_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PersistedDecision {
pub capability: String,
pub allow: bool,
pub decided_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_range: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PermissionsFile {
version: u32,
decisions: HashMap<String, Vec<PersistedDecision>>,
}
impl Default for PermissionsFile {
fn default() -> Self {
Self {
version: CURRENT_VERSION,
decisions: HashMap::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct PermissionStore {
path: PathBuf,
decisions: HashMap<String, HashMap<String, PersistedDecision>>,
}
impl PermissionStore {
pub fn open_default() -> Result<Self> {
Self::open(&Config::permissions_path())
}
pub fn open(path: &Path) -> Result<Self> {
let decisions = if path.exists() {
let raw = std::fs::read_to_string(path).map_err(|e| {
Error::config(format!(
"Failed to read permissions file {}: {e}",
path.display()
))
})?;
let file: PermissionsFile = serde_json::from_str(&raw).map_err(|e| {
Error::config(format!(
"Failed to parse permissions file {}: {e}",
path.display()
))
})?;
file.decisions
.into_iter()
.map(|(ext_id, decs)| {
let by_cap: HashMap<String, PersistedDecision> = decs
.into_iter()
.map(|d| (d.capability.clone(), d))
.collect();
(ext_id, by_cap)
})
.collect()
} else {
HashMap::new()
};
Ok(Self {
path: path.to_path_buf(),
decisions,
})
}
pub fn lookup(&self, extension_id: &str, capability: &str) -> Option<bool> {
let by_cap = self.decisions.get(extension_id)?;
let dec = by_cap.get(capability)?;
if let Some(ref exp) = dec.expires_at {
let now = now_iso8601();
if now > *exp {
return None;
}
}
Some(dec.allow)
}
pub fn record(&mut self, extension_id: &str, capability: &str, allow: bool) -> Result<()> {
let decision = PersistedDecision {
capability: capability.to_string(),
allow,
decided_at: now_iso8601(),
expires_at: None,
version_range: None,
};
self.decisions
.entry(extension_id.to_string())
.or_default()
.insert(capability.to_string(), decision);
self.save()
}
pub fn record_with_version(
&mut self,
extension_id: &str,
capability: &str,
allow: bool,
version_range: &str,
) -> Result<()> {
let decision = PersistedDecision {
capability: capability.to_string(),
allow,
decided_at: now_iso8601(),
expires_at: None,
version_range: Some(version_range.to_string()),
};
self.decisions
.entry(extension_id.to_string())
.or_default()
.insert(capability.to_string(), decision);
self.save()
}
pub fn revoke_extension(&mut self, extension_id: &str) -> Result<()> {
self.decisions.remove(extension_id);
self.save()
}
pub fn reset(&mut self) -> Result<()> {
self.decisions.clear();
self.save()
}
pub const fn list(&self) -> &HashMap<String, HashMap<String, PersistedDecision>> {
&self.decisions
}
pub fn to_cache_map(&self) -> HashMap<String, HashMap<String, bool>> {
let now = now_iso8601();
self.decisions
.iter()
.map(|(ext_id, by_cap)| {
let filtered: HashMap<String, bool> = by_cap
.iter()
.filter(|(_, dec)| dec.expires_at.as_ref().is_none_or(|exp| now <= *exp))
.map(|(cap, dec)| (cap.clone(), dec.allow))
.collect();
(ext_id.clone(), filtered)
})
.filter(|(_, m)| !m.is_empty())
.collect()
}
pub fn to_decision_cache(&self) -> HashMap<String, HashMap<String, PersistedDecision>> {
let now = now_iso8601();
self.decisions
.iter()
.map(|(ext_id, by_cap)| {
let filtered: HashMap<String, PersistedDecision> = by_cap
.iter()
.filter(|(_, dec)| dec.expires_at.as_ref().is_none_or(|exp| now <= *exp))
.map(|(cap, dec)| (cap.clone(), dec.clone()))
.collect();
(ext_id.clone(), filtered)
})
.filter(|(_, m)| !m.is_empty())
.collect()
}
fn save(&self) -> Result<()> {
let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
let file = PermissionsFile {
version: CURRENT_VERSION,
decisions: self
.decisions
.iter()
.map(|(ext_id, by_cap)| {
let decs: Vec<PersistedDecision> = by_cap.values().cloned().collect();
(ext_id.clone(), decs)
})
.collect(),
};
let mut contents = serde_json::to_string_pretty(&file)?;
contents.push('\n');
let mut tmp = NamedTempFile::new_in(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let perms = std::fs::Permissions::from_mode(0o600);
tmp.as_file().set_permissions(perms)?;
}
tmp.write_all(contents.as_bytes())?;
tmp.as_file().sync_all()?;
tmp.persist(&self.path).map_err(|err| {
Error::config(format!(
"Failed to persist permissions file to {}: {}",
self.path.display(),
err.error
))
})?;
Ok(())
}
}
fn now_iso8601() -> String {
let now = std::time::SystemTime::now();
let duration = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
let days = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let (year, month, day) = days_to_ymd(days);
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
}
const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let store = PermissionStore::open(&path).unwrap();
assert!(store.list().is_empty());
assert!(!path.exists());
}
#[test]
fn record_and_lookup() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store.record("my-ext", "exec", true).unwrap();
store.record("my-ext", "env", false).unwrap();
store.record("other-ext", "http", true).unwrap();
assert_eq!(store.lookup("my-ext", "exec"), Some(true));
assert_eq!(store.lookup("my-ext", "env"), Some(false));
assert_eq!(store.lookup("other-ext", "http"), Some(true));
assert_eq!(store.lookup("unknown", "exec"), None);
assert_eq!(store.lookup("my-ext", "unknown"), None);
let store2 = PermissionStore::open(&path).unwrap();
assert_eq!(store2.lookup("my-ext", "exec"), Some(true));
assert_eq!(store2.lookup("my-ext", "env"), Some(false));
assert_eq!(store2.lookup("other-ext", "http"), Some(true));
}
#[test]
fn revoke_extension() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store.record("my-ext", "exec", true).unwrap();
store.record("my-ext", "env", false).unwrap();
store.record("other-ext", "http", true).unwrap();
store.revoke_extension("my-ext").unwrap();
assert_eq!(store.lookup("my-ext", "exec"), None);
assert_eq!(store.lookup("my-ext", "env"), None);
assert_eq!(store.lookup("other-ext", "http"), Some(true));
let store2 = PermissionStore::open(&path).unwrap();
assert_eq!(store2.lookup("my-ext", "exec"), None);
}
#[test]
fn reset_all() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store.record("a", "exec", true).unwrap();
store.record("b", "http", false).unwrap();
store.reset().unwrap();
assert!(store.list().is_empty());
let store2 = PermissionStore::open(&path).unwrap();
assert!(store2.list().is_empty());
}
#[test]
fn to_cache_map_filters_expired() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store
.decisions
.entry("ext1".to_string())
.or_default()
.insert(
"exec".to_string(),
PersistedDecision {
capability: "exec".to_string(),
allow: true,
decided_at: "2026-01-01T00:00:00Z".to_string(),
expires_at: Some("2099-12-31T23:59:59Z".to_string()),
version_range: None,
},
);
store
.decisions
.entry("ext1".to_string())
.or_default()
.insert(
"env".to_string(),
PersistedDecision {
capability: "env".to_string(),
allow: false,
decided_at: "2020-01-01T00:00:00Z".to_string(),
expires_at: Some("2020-06-01T00:00:00Z".to_string()),
version_range: None,
},
);
let cache = store.to_cache_map();
assert_eq!(cache.get("ext1").and_then(|m| m.get("exec")), Some(&true));
assert_eq!(cache.get("ext1").and_then(|m| m.get("env")), None);
}
#[test]
fn overwrite_decision() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store.record("ext", "exec", true).unwrap();
assert_eq!(store.lookup("ext", "exec"), Some(true));
store.record("ext", "exec", false).unwrap();
assert_eq!(store.lookup("ext", "exec"), Some(false));
let store2 = PermissionStore::open(&path).unwrap();
assert_eq!(store2.lookup("ext", "exec"), Some(false));
}
#[test]
fn version_range_stored() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store
.record_with_version("ext", "exec", true, ">=1.0.0")
.unwrap();
let store2 = PermissionStore::open(&path).unwrap();
let dec = store2
.decisions
.get("ext")
.and_then(|m| m.get("exec"))
.unwrap();
assert_eq!(dec.version_range.as_deref(), Some(">=1.0.0"));
assert!(dec.allow);
}
#[test]
fn now_iso8601_format() {
let ts = now_iso8601();
assert_eq!(ts.len(), 20);
assert!(ts.ends_with('Z'));
assert_eq!(ts.as_bytes()[4], b'-');
assert_eq!(ts.as_bytes()[7], b'-');
assert_eq!(ts.as_bytes()[10], b'T');
assert_eq!(ts.as_bytes()[13], b':');
assert_eq!(ts.as_bytes()[16], b':');
}
#[test]
fn days_to_ymd_epoch() {
assert_eq!(days_to_ymd(0), (1970, 1, 1));
}
#[test]
fn days_to_ymd_known_dates() {
assert_eq!(days_to_ymd(10957), (2000, 1, 1));
assert_eq!(days_to_ymd(11016), (2000, 2, 29));
assert_eq!(days_to_ymd(11017), (2000, 3, 1));
assert_eq!(days_to_ymd(19723), (2024, 1, 1));
}
#[test]
fn days_to_ymd_dec_31() {
assert_eq!(days_to_ymd(364), (1970, 12, 31));
assert_eq!(days_to_ymd(365), (1971, 1, 1));
}
#[test]
fn days_to_ymd_leap_year_boundary() {
assert_eq!(days_to_ymd(789), (1972, 2, 29));
assert_eq!(days_to_ymd(790), (1972, 3, 1));
}
#[test]
fn days_to_ymd_far_future() {
assert_eq!(days_to_ymd(47_481), (2099, 12, 31));
assert_eq!(days_to_ymd(47_482), (2100, 1, 1));
}
#[test]
fn now_iso8601_lexicographic_order() {
let ts1 = now_iso8601();
let ts2 = now_iso8601();
assert!(ts2 >= ts1);
}
#[test]
fn now_iso8601_year_plausible() {
let ts = now_iso8601();
let year: u32 = ts[0..4].parse().unwrap();
assert!(year >= 2024);
assert!(year <= 2100);
}
#[test]
fn persisted_decision_serde_minimal() {
let dec = PersistedDecision {
capability: "exec".to_string(),
allow: true,
decided_at: "2026-01-15T10:30:00Z".to_string(),
expires_at: None,
version_range: None,
};
let json = serde_json::to_string(&dec).unwrap();
assert!(!json.contains("expires_at"));
assert!(!json.contains("version_range"));
let roundtrip: PersistedDecision = serde_json::from_str(&json).unwrap();
assert_eq!(roundtrip, dec);
}
#[test]
fn persisted_decision_serde_full() {
let dec = PersistedDecision {
capability: "http".to_string(),
allow: false,
decided_at: "2026-01-15T10:30:00Z".to_string(),
expires_at: Some("2026-06-15T10:30:00Z".to_string()),
version_range: Some(">=2.0.0".to_string()),
};
let json = serde_json::to_string(&dec).unwrap();
assert!(json.contains("expires_at"));
assert!(json.contains("version_range"));
let roundtrip: PersistedDecision = serde_json::from_str(&json).unwrap();
assert_eq!(roundtrip, dec);
}
#[test]
fn persisted_decision_deserialize_missing_optionals() {
let json = r#"{"capability":"exec","allow":true,"decided_at":"2026-01-01T00:00:00Z"}"#;
let dec: PersistedDecision = serde_json::from_str(json).unwrap();
assert_eq!(dec.capability, "exec");
assert!(dec.allow);
assert!(dec.expires_at.is_none());
assert!(dec.version_range.is_none());
}
#[test]
fn permissions_file_default_version() {
let file = PermissionsFile::default();
assert_eq!(file.version, CURRENT_VERSION);
assert!(file.decisions.is_empty());
}
#[test]
fn permissions_file_serde_roundtrip() {
let mut decisions = HashMap::new();
decisions.insert(
"ext-a".to_string(),
vec![PersistedDecision {
capability: "exec".to_string(),
allow: true,
decided_at: "2026-01-01T00:00:00Z".to_string(),
expires_at: None,
version_range: None,
}],
);
let file = PermissionsFile {
version: CURRENT_VERSION,
decisions,
};
let json = serde_json::to_string_pretty(&file).unwrap();
let roundtrip: PermissionsFile = serde_json::from_str(&json).unwrap();
assert_eq!(roundtrip.version, CURRENT_VERSION);
assert_eq!(roundtrip.decisions.len(), 1);
assert_eq!(roundtrip.decisions["ext-a"].len(), 1);
assert_eq!(roundtrip.decisions["ext-a"][0].capability, "exec");
}
#[test]
fn open_corrupt_file_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
std::fs::write(&path, "not valid json!!!").unwrap();
let result = PermissionStore::open(&path);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(err_msg.contains("parse"));
}
#[test]
fn open_empty_json_object_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
std::fs::write(&path, "{}").unwrap();
let result = PermissionStore::open(&path);
assert!(result.is_err());
}
#[test]
fn open_valid_empty_decisions() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
std::fs::write(&path, r#"{"version":1,"decisions":{}}"#).unwrap();
let store = PermissionStore::open(&path).unwrap();
assert!(store.list().is_empty());
}
#[test]
fn lookup_expired_decision_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store
.decisions
.entry("ext".to_string())
.or_default()
.insert(
"exec".to_string(),
PersistedDecision {
capability: "exec".to_string(),
allow: true,
decided_at: "2020-01-01T00:00:00Z".to_string(),
expires_at: Some("2020-06-01T00:00:00Z".to_string()),
version_range: None,
},
);
assert_eq!(store.lookup("ext", "exec"), None);
}
#[test]
fn lookup_future_expiry_returns_decision() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store
.decisions
.entry("ext".to_string())
.or_default()
.insert(
"exec".to_string(),
PersistedDecision {
capability: "exec".to_string(),
allow: false,
decided_at: "2026-01-01T00:00:00Z".to_string(),
expires_at: Some("2099-12-31T23:59:59Z".to_string()),
version_range: None,
},
);
assert_eq!(store.lookup("ext", "exec"), Some(false));
}
#[test]
fn lookup_no_expiry_returns_decision() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store
.decisions
.entry("ext".to_string())
.or_default()
.insert(
"exec".to_string(),
PersistedDecision {
capability: "exec".to_string(),
allow: true,
decided_at: "2026-01-01T00:00:00Z".to_string(),
expires_at: None,
version_range: None,
},
);
assert_eq!(store.lookup("ext", "exec"), Some(true));
}
#[test]
fn record_creates_parent_directories() {
let dir = tempfile::tempdir().unwrap();
let path = dir
.path()
.join("deep")
.join("nested")
.join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store.record("ext", "exec", true).unwrap();
assert!(path.exists());
let store2 = PermissionStore::open(&path).unwrap();
assert_eq!(store2.lookup("ext", "exec"), Some(true));
}
#[test]
fn multiple_capabilities_per_extension() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store.record("ext", "exec", true).unwrap();
store.record("ext", "http", false).unwrap();
store.record("ext", "env", true).unwrap();
store.record("ext", "fs", false).unwrap();
assert_eq!(store.lookup("ext", "exec"), Some(true));
assert_eq!(store.lookup("ext", "http"), Some(false));
assert_eq!(store.lookup("ext", "env"), Some(true));
assert_eq!(store.lookup("ext", "fs"), Some(false));
let store2 = PermissionStore::open(&path).unwrap();
assert_eq!(store2.lookup("ext", "exec"), Some(true));
assert_eq!(store2.lookup("ext", "http"), Some(false));
assert_eq!(store2.lookup("ext", "env"), Some(true));
assert_eq!(store2.lookup("ext", "fs"), Some(false));
}
#[test]
fn record_with_version_stores_range() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store
.record_with_version("ext", "exec", true, "^1.0.0")
.unwrap();
store
.record_with_version("ext", "http", false, ">=2.0.0 <3.0.0")
.unwrap();
let dec_exec = store.decisions["ext"].get("exec").unwrap();
assert_eq!(dec_exec.version_range.as_deref(), Some("^1.0.0"));
assert!(dec_exec.allow);
let dec_http = store.decisions["ext"].get("http").unwrap();
assert_eq!(dec_http.version_range.as_deref(), Some(">=2.0.0 <3.0.0"));
assert!(!dec_http.allow);
}
#[test]
fn record_with_version_overwrites_previous() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store
.record_with_version("ext", "exec", true, "^1.0.0")
.unwrap();
store
.record_with_version("ext", "exec", false, "^2.0.0")
.unwrap();
let dec = store.decisions["ext"].get("exec").unwrap();
assert_eq!(dec.version_range.as_deref(), Some("^2.0.0"));
assert!(!dec.allow);
}
#[test]
fn list_returns_all_decisions() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store.record("ext-a", "exec", true).unwrap();
store.record("ext-a", "http", false).unwrap();
store.record("ext-b", "env", true).unwrap();
let all = store.list();
assert_eq!(all.len(), 2);
assert_eq!(all["ext-a"].len(), 2);
assert_eq!(all["ext-b"].len(), 1);
}
#[test]
fn revoke_nonexistent_extension_is_noop() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store.record("ext", "exec", true).unwrap();
store.revoke_extension("nonexistent").unwrap();
assert_eq!(store.lookup("ext", "exec"), Some(true));
}
#[test]
fn to_cache_map_all_expired_removes_extension() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store
.decisions
.entry("ext".to_string())
.or_default()
.insert(
"exec".to_string(),
PersistedDecision {
capability: "exec".to_string(),
allow: true,
decided_at: "2020-01-01T00:00:00Z".to_string(),
expires_at: Some("2020-06-01T00:00:00Z".to_string()),
version_range: None,
},
);
store
.decisions
.entry("ext".to_string())
.or_default()
.insert(
"http".to_string(),
PersistedDecision {
capability: "http".to_string(),
allow: false,
decided_at: "2020-01-01T00:00:00Z".to_string(),
expires_at: Some("2020-06-01T00:00:00Z".to_string()),
version_range: None,
},
);
let cache = store.to_cache_map();
assert!(!cache.contains_key("ext"));
}
#[test]
fn to_cache_map_no_expiry_always_included() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store
.decisions
.entry("ext".to_string())
.or_default()
.insert(
"exec".to_string(),
PersistedDecision {
capability: "exec".to_string(),
allow: true,
decided_at: "2026-01-01T00:00:00Z".to_string(),
expires_at: None,
version_range: None,
},
);
let cache = store.to_cache_map();
assert_eq!(cache.get("ext").and_then(|m| m.get("exec")), Some(&true));
}
#[test]
fn to_cache_map_multiple_extensions() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store.record("ext-a", "exec", true).unwrap();
store.record("ext-a", "http", false).unwrap();
store.record("ext-b", "env", true).unwrap();
let cache = store.to_cache_map();
assert_eq!(cache.len(), 2);
assert_eq!(cache.get("ext-a").and_then(|m| m.get("exec")), Some(&true));
assert_eq!(cache.get("ext-a").and_then(|m| m.get("http")), Some(&false));
assert_eq!(cache.get("ext-b").and_then(|m| m.get("env")), Some(&true));
}
#[cfg(unix)]
#[test]
fn save_sets_file_permissions_0o600() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store.record("ext", "exec", true).unwrap();
let metadata = std::fs::metadata(&path).unwrap();
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn multiple_saves_and_reloads() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
{
let mut store = PermissionStore::open(&path).unwrap();
store.record("ext-a", "exec", true).unwrap();
store.record("ext-b", "http", false).unwrap();
}
{
let mut store = PermissionStore::open(&path).unwrap();
store.record("ext-a", "exec", false).unwrap(); store.record("ext-c", "env", true).unwrap(); }
{
let store = PermissionStore::open(&path).unwrap();
assert_eq!(store.lookup("ext-a", "exec"), Some(false)); assert_eq!(store.lookup("ext-b", "http"), Some(false)); assert_eq!(store.lookup("ext-c", "env"), Some(true)); }
}
#[test]
fn reset_then_record_works() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store.record("ext", "exec", true).unwrap();
store.reset().unwrap();
store.record("ext", "http", false).unwrap();
assert_eq!(store.lookup("ext", "exec"), None);
assert_eq!(store.lookup("ext", "http"), Some(false));
let store2 = PermissionStore::open(&path).unwrap();
assert_eq!(store2.lookup("ext", "exec"), None);
assert_eq!(store2.lookup("ext", "http"), Some(false));
}
#[test]
fn decided_at_is_recent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("permissions.json");
let mut store = PermissionStore::open(&path).unwrap();
store.record("ext", "exec", true).unwrap();
let dec = &store.decisions["ext"]["exec"];
let year: u32 = dec.decided_at[0..4].parse().unwrap();
assert!(year >= 2024);
}
mod proptest_permissions {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn days_to_ymd_valid_ranges(days in 0..100_000u64) {
let (y, m, d) = days_to_ymd(days);
assert!(y >= 1970, "year {y} too small for days={days}");
assert!((1..=12).contains(&m), "month {m} out of range");
assert!((1..=31).contains(&d), "day {d} out of range");
}
#[test]
fn days_to_ymd_epoch(_dummy in 0..1u8) {
let (y, m, d) = days_to_ymd(0);
assert_eq!((y, m, d), (1970, 1, 1));
}
#[test]
fn days_to_ymd_consecutive(days in 0..99_999u64) {
let (y1, m1, d1) = days_to_ymd(days);
let (y2, m2, d2) = days_to_ymd(days + 1);
if d2 == d1 + 1 && m2 == m1 && y2 == y1 {
} else if d2 == 1 {
assert!(m2 != m1 || y2 != y1);
} else {
panic!("unexpected day sequence: {y1}-{m1}-{d1} -> {y2}-{m2}-{d2}");
}
}
#[test]
fn now_iso8601_format(_dummy in 0..1u8) {
let ts = now_iso8601();
assert_eq!(ts.len(), 20, "expected YYYY-MM-DDThh:mm:ssZ, got {ts}");
assert!(ts.ends_with('Z'));
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[7..8], "-");
assert_eq!(&ts[10..11], "T");
assert_eq!(&ts[13..14], ":");
assert_eq!(&ts[16..17], ":");
}
#[test]
fn decision_serde_roundtrip(
cap in "[a-z]{1,10}",
allow in proptest::bool::ANY,
has_expiry in proptest::bool::ANY,
has_range in proptest::bool::ANY
) {
let dec = PersistedDecision {
capability: cap,
allow,
decided_at: "2025-01-01T00:00:00Z".to_string(),
expires_at: if has_expiry { Some("2030-01-01T00:00:00Z".to_string()) } else { None },
version_range: if has_range { Some(">=1.0.0".to_string()) } else { None },
};
let json = serde_json::to_string(&dec).unwrap();
let back: PersistedDecision = serde_json::from_str(&json).unwrap();
assert_eq!(dec, back);
}
#[test]
fn record_lookup_roundtrip(
ext_id in "[a-z]{1,8}",
cap in "[a-z]{1,8}",
allow in proptest::bool::ANY
) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("perm.json");
let mut store = PermissionStore::open(&path).unwrap();
store.record(&ext_id, &cap, allow).unwrap();
assert_eq!(store.lookup(&ext_id, &cap), Some(allow));
}
#[test]
fn lookup_unknown_extension(ext in "[a-z]{1,10}", cap in "[a-z]{1,5}") {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("perm.json");
let store = PermissionStore::open(&path).unwrap();
assert_eq!(store.lookup(&ext, &cap), None);
}
#[test]
fn record_overwrites(ext in "[a-z]{1,8}", cap in "[a-z]{1,8}") {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("perm.json");
let mut store = PermissionStore::open(&path).unwrap();
store.record(&ext, &cap, true).unwrap();
assert_eq!(store.lookup(&ext, &cap), Some(true));
store.record(&ext, &cap, false).unwrap();
assert_eq!(store.lookup(&ext, &cap), Some(false));
}
#[test]
fn revoke_removes_all(ext in "[a-z]{1,8}", cap1 in "[a-z]{1,5}", cap2 in "[a-z]{1,5}") {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("perm.json");
let mut store = PermissionStore::open(&path).unwrap();
store.record(&ext, &cap1, true).unwrap();
store.record(&ext, &cap2, false).unwrap();
store.revoke_extension(&ext).unwrap();
assert_eq!(store.lookup(&ext, &cap1), None);
assert_eq!(store.lookup(&ext, &cap2), None);
}
#[test]
fn days_to_ymd_year_boundary(_dummy in 0..1u8) {
let (y, m, d) = days_to_ymd(365);
assert_eq!(y, 1971);
assert_eq!(m, 1);
assert_eq!(d, 1);
}
#[test]
fn days_to_ymd_leap_day_2000(_dummy in 0..1u8) {
let (y, m, d) = days_to_ymd(11016);
assert_eq!((y, m, d), (2000, 2, 29));
}
}
}
}