use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use crate::policy::SandboxPolicy;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ProfileKey {
pub canonical_root: PathBuf,
pub home: String,
pub proxy: Option<(u16, bool, bool)>,
pub policy: SandboxPolicy,
}
fn cache() -> &'static Mutex<HashMap<ProfileKey, String>> {
static CACHE: OnceLock<Mutex<HashMap<ProfileKey, String>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn get_or_compute<F>(key: ProfileKey, build: F) -> String
where
F: FnOnce(&ProfileKey) -> String,
{
let mut map = cache()
.lock()
.expect("seatbelt profile cache mutex poisoned");
if let Some(profile) = map.get(&key) {
return profile.clone();
}
let profile = build(&key);
map.insert(key, profile.clone());
profile
}
#[cfg(test)]
pub fn clear() {
cache()
.lock()
.expect("seatbelt profile cache mutex poisoned")
.clear();
}
#[cfg(test)]
pub fn len() -> usize {
cache()
.lock()
.expect("seatbelt profile cache mutex poisoned")
.len()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn key_with_root(root: &str) -> ProfileKey {
ProfileKey {
canonical_root: PathBuf::from(root),
home: "/Users/test".into(),
proxy: None,
policy: SandboxPolicy::default(),
}
}
#[test]
fn build_runs_exactly_once_per_unique_key() {
clear();
let calls = AtomicUsize::new(0);
let key = key_with_root("/test/once-per-key");
let p1 = get_or_compute(key.clone(), |_| {
calls.fetch_add(1, Ordering::SeqCst);
"PROFILE_A".to_string()
});
let p2 = get_or_compute(key.clone(), |_| {
calls.fetch_add(1, Ordering::SeqCst);
"PROFILE_B".to_string()
});
assert_eq!(p1, "PROFILE_A");
assert_eq!(
p2, "PROFILE_A",
"second call must return cached value, not re-run build"
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"build closure must run exactly once for the same key"
);
}
#[test]
fn distinct_keys_get_distinct_profiles() {
let key_a = key_with_root("/test/distinct-a");
let key_b = key_with_root("/test/distinct-b");
let pa = get_or_compute(key_a.clone(), |k| {
format!("ROOT={}", k.canonical_root.display())
});
let pb = get_or_compute(key_b.clone(), |k| {
format!("ROOT={}", k.canonical_root.display())
});
assert_eq!(pa, "ROOT=/test/distinct-a");
assert_eq!(pb, "ROOT=/test/distinct-b");
assert_ne!(pa, pb);
}
#[test]
fn proxy_change_invalidates_cache() {
let mut k_open = key_with_root("/test/proxy-invalidates");
k_open.proxy = None;
let mut k_proxied = key_with_root("/test/proxy-invalidates");
k_proxied.proxy = Some((8080, false, false));
let p_open = get_or_compute(k_open, |_| "OPEN".to_string());
let p_proxied = get_or_compute(k_proxied, |_| "PROXIED".to_string());
assert_eq!(p_open, "OPEN");
assert_eq!(
p_proxied, "PROXIED",
"proxy=Some must NOT return the proxy=None cached value"
);
}
#[test]
fn policy_change_invalidates_cache() {
use crate::policy::PathPattern;
let mut k_loose = key_with_root("/test/policy-invalidates");
k_loose.policy = SandboxPolicy::default();
let mut k_tight = key_with_root("/test/policy-invalidates");
k_tight.policy = SandboxPolicy {
fs: crate::policy::FsPolicy {
deny_read: vec![PathPattern("/etc/secret".into())],
..Default::default()
},
..Default::default()
};
let p_loose = get_or_compute(k_loose, |_| "LOOSE".to_string());
let p_tight = get_or_compute(k_tight, |_| "TIGHT".to_string());
assert_ne!(
p_loose, p_tight,
"policy delta must produce a fresh profile"
);
}
#[test]
fn weaker_isolation_flag_change_invalidates_cache() {
let mut k_strict = key_with_root("/test/weaker-iso");
k_strict.proxy = Some((8080, false, false));
let mut k_weak = key_with_root("/test/weaker-iso");
k_weak.proxy = Some((8080, false, true));
let p_strict = get_or_compute(k_strict, |_| "STRICT".to_string());
let p_weak = get_or_compute(k_weak, |_| "WEAK".to_string());
assert_ne!(
p_strict, p_weak,
"weaker_macos_isolation toggle must produce distinct cache entries"
);
}
#[test]
fn clear_resets_the_cache() {
let key = key_with_root("/test/clear-resets");
let calls = AtomicUsize::new(0);
get_or_compute(key.clone(), |_| {
calls.fetch_add(1, Ordering::SeqCst);
"FIRST".to_string()
});
assert_eq!(calls.load(Ordering::SeqCst), 1);
get_or_compute(key.clone(), |_| {
calls.fetch_add(1, Ordering::SeqCst);
"SECOND".to_string()
});
assert_eq!(calls.load(Ordering::SeqCst), 1, "should still be cached");
clear();
get_or_compute(key, |_| {
calls.fetch_add(1, Ordering::SeqCst);
"THIRD".to_string()
});
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"clear() must force the next get to rebuild"
);
}
}