use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::decision::TrustDecision;
#[derive(Debug)]
pub enum TrustStoreError {
Io {
path: PathBuf,
source: std::io::Error,
},
Parse { path: PathBuf, message: String },
UnsupportedVersion { path: PathBuf, version: u32 },
InvalidDecision { reason: &'static str },
}
impl std::fmt::Display for TrustStoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TrustStoreError::Io { path, source } => {
write!(f, "{}: trust store io error: {source}", path.display())
}
TrustStoreError::Parse { path, message } => {
write!(f, "{}: trust store parse error: {message}", path.display())
}
TrustStoreError::UnsupportedVersion { path, version } => write!(
f,
"{}: trust store version {version} is newer than this host supports (1)",
path.display()
),
TrustStoreError::InvalidDecision { reason } => {
write!(f, "trust store: invalid decision: {reason}")
}
}
}
}
impl std::error::Error for TrustStoreError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
TrustStoreError::Io { source, .. } => Some(source),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TrustKey {
pub namespace: String,
pub command_string: String,
}
#[derive(Debug)]
pub struct TrustStore {
path: PathBuf,
entries: HashMap<TrustKey, TrustDecision>,
}
impl TrustStore {
pub fn open(workspace: impl AsRef<Path>) -> Result<Self, TrustStoreError> {
let path = workspace.as_ref().join(".lex").join("trust.json");
let entries = match fs::read_to_string(&path) {
Ok(body) => parse_disk_format(&body, &path)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => HashMap::new(),
Err(source) => {
return Err(TrustStoreError::Io {
path: path.clone(),
source,
});
}
};
Ok(Self { path, entries })
}
pub fn get(&self, key: &TrustKey) -> Option<&TrustDecision> {
self.entries.get(key)
}
pub fn set(&mut self, key: TrustKey, decision: TrustDecision) -> Result<(), TrustStoreError> {
if matches!(decision, TrustDecision::Pending) {
return Err(TrustStoreError::InvalidDecision {
reason: "TrustDecision::Pending is an internal in-flight state; only Trusted and Denied are storable",
});
}
let mut next = self.entries.clone();
next.insert(key, decision);
self.flush_entries(&next)?;
self.entries = next;
Ok(())
}
pub fn clear(&mut self) -> Result<(), TrustStoreError> {
let empty = HashMap::new();
self.flush_entries(&empty)?;
self.entries = empty;
Ok(())
}
pub fn iter(&self) -> impl Iterator<Item = (&TrustKey, &TrustDecision)> {
self.entries.iter()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn flush_entries(
&self,
entries: &HashMap<TrustKey, TrustDecision>,
) -> Result<(), TrustStoreError> {
use std::io::Write;
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).map_err(|source| TrustStoreError::Io {
path: parent.to_path_buf(),
source,
})?;
}
let body = serialize_disk_format(entries);
let tmp = self.path.with_extension("json.tmp");
let mut tmp_file = fs::File::create(&tmp).map_err(|source| TrustStoreError::Io {
path: tmp.clone(),
source,
})?;
tmp_file
.write_all(body.as_bytes())
.map_err(|source| TrustStoreError::Io {
path: tmp.clone(),
source,
})?;
tmp_file.sync_data().map_err(|source| TrustStoreError::Io {
path: tmp.clone(),
source,
})?;
drop(tmp_file);
fs::rename(&tmp, &self.path).map_err(|source| {
TrustStoreError::Io {
path: self.path.clone(),
source,
}
})
}
}
#[derive(Debug, Serialize, Deserialize)]
struct OnDiskFile {
version: u32,
entries: Vec<OnDiskEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
struct OnDiskEntry {
namespace: String,
command_string: String,
decision: OnDiskDecision,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum OnDiskDecision {
Trusted,
Denied { reason: String },
}
fn parse_disk_format(
body: &str,
path: &Path,
) -> Result<HashMap<TrustKey, TrustDecision>, TrustStoreError> {
let parsed: OnDiskFile = serde_json::from_str(body).map_err(|err| TrustStoreError::Parse {
path: path.to_path_buf(),
message: err.to_string(),
})?;
if parsed.version != 1 {
return Err(TrustStoreError::UnsupportedVersion {
path: path.to_path_buf(),
version: parsed.version,
});
}
let mut out = HashMap::with_capacity(parsed.entries.len());
for entry in parsed.entries {
let key = TrustKey {
namespace: entry.namespace,
command_string: entry.command_string,
};
let decision = match entry.decision {
OnDiskDecision::Trusted => TrustDecision::Trusted,
OnDiskDecision::Denied { reason } => TrustDecision::Denied { reason },
};
out.insert(key, decision);
}
Ok(out)
}
fn serialize_disk_format(entries: &HashMap<TrustKey, TrustDecision>) -> String {
let mut on_disk: Vec<OnDiskEntry> = entries
.iter()
.filter_map(|(k, v)| {
let decision = match v {
TrustDecision::Trusted => OnDiskDecision::Trusted,
TrustDecision::Denied { reason } => OnDiskDecision::Denied {
reason: reason.clone(),
},
TrustDecision::Pending => return None,
};
Some(OnDiskEntry {
namespace: k.namespace.clone(),
command_string: k.command_string.clone(),
decision,
})
})
.collect();
on_disk.sort_by(|a, b| {
a.namespace
.cmp(&b.namespace)
.then(a.command_string.cmp(&b.command_string))
});
let file = OnDiskFile {
version: 1,
entries: on_disk,
};
serde_json::to_string_pretty(&file).expect("OnDiskFile serialises")
}
#[cfg(test)]
mod tests {
use super::*;
fn key(ns: &str, cmd: &str) -> TrustKey {
TrustKey {
namespace: ns.into(),
command_string: cmd.into(),
}
}
#[test]
fn missing_file_yields_empty_store() {
let dir = tempfile::tempdir().unwrap();
let store = TrustStore::open(dir.path()).expect("open empty");
assert!(store.is_empty());
}
#[test]
fn set_persists_and_round_trips() {
let dir = tempfile::tempdir().unwrap();
{
let mut store = TrustStore::open(dir.path()).unwrap();
store
.set(key("acme", "acme-handler"), TrustDecision::Trusted)
.unwrap();
store
.set(
key("evil", "evil-binary"),
TrustDecision::Denied {
reason: "rejected".into(),
},
)
.unwrap();
}
let store = TrustStore::open(dir.path()).expect("reopen");
assert_eq!(store.len(), 2);
assert_eq!(
store.get(&key("acme", "acme-handler")),
Some(&TrustDecision::Trusted)
);
match store.get(&key("evil", "evil-binary")) {
Some(TrustDecision::Denied { reason }) => assert_eq!(reason, "rejected"),
other => panic!("expected Denied, got: {other:?}"),
}
}
#[test]
fn pending_returns_invalid_decision_error_and_does_not_persist() {
let dir = tempfile::tempdir().unwrap();
let mut store = TrustStore::open(dir.path()).unwrap();
let err = store
.set(key("acme", "acme-handler"), TrustDecision::Pending)
.unwrap_err();
assert!(matches!(err, TrustStoreError::InvalidDecision { .. }));
assert!(store.is_empty());
let store = TrustStore::open(dir.path()).unwrap();
assert!(store.is_empty());
}
#[test]
fn clear_wipes_all_entries_and_persists() {
let dir = tempfile::tempdir().unwrap();
{
let mut store = TrustStore::open(dir.path()).unwrap();
store.set(key("acme", "x"), TrustDecision::Trusted).unwrap();
store.clear().unwrap();
assert!(store.is_empty());
}
let store = TrustStore::open(dir.path()).unwrap();
assert!(store.is_empty());
}
#[test]
fn iter_yields_every_entry() {
let dir = tempfile::tempdir().unwrap();
let mut store = TrustStore::open(dir.path()).unwrap();
store.set(key("a", "1"), TrustDecision::Trusted).unwrap();
store
.set(
key("b", "2"),
TrustDecision::Denied {
reason: "no".into(),
},
)
.unwrap();
let mut seen: Vec<String> = store.iter().map(|(k, _)| k.namespace.clone()).collect();
seen.sort();
assert_eq!(seen, vec!["a", "b"]);
}
#[test]
fn atomic_flush_leaves_no_tempfile_after_set() {
let dir = tempfile::tempdir().unwrap();
{
let mut store = TrustStore::open(dir.path()).unwrap();
store.set(key("acme", "x"), TrustDecision::Trusted).unwrap();
}
let lex_dir = dir.path().join(".lex");
let mut entries: Vec<String> = std::fs::read_dir(&lex_dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
entries.sort();
assert_eq!(
entries,
vec!["trust.json".to_string()],
"tempfile must be renamed away, leaving only the canonical file"
);
}
#[test]
fn denied_disk_format_matches_documented_shape() {
let dir = tempfile::tempdir().unwrap();
{
let mut store = TrustStore::open(dir.path()).unwrap();
store
.set(
key("evil", "evil-bin"),
TrustDecision::Denied {
reason: "user rejected".into(),
},
)
.unwrap();
}
let body = std::fs::read_to_string(dir.path().join(".lex/trust.json")).unwrap();
assert!(
body.contains(r#""denied""#) && body.contains(r#""reason": "user rejected""#),
"denied entry must serialise as {{\"denied\": {{\"reason\": ...}}}}, got:\n{body}"
);
}
#[test]
fn set_failure_leaves_in_memory_and_disk_consistent() {
let dir = tempfile::tempdir().unwrap();
{
let mut store = TrustStore::open(dir.path()).unwrap();
store
.set(key("acme", "acme-handler"), TrustDecision::Trusted)
.unwrap();
}
let lex_dir = dir.path().join(".lex");
let saved_body = std::fs::read_to_string(lex_dir.join("trust.json")).unwrap();
std::fs::remove_dir_all(&lex_dir).unwrap();
std::fs::write(&lex_dir, b"i am now a file").unwrap();
let mut store = TrustStore {
path: lex_dir.join("trust.json"),
entries: {
let mut m = HashMap::new();
m.insert(key("acme", "acme-handler"), TrustDecision::Trusted);
m
},
};
let err = store
.set(
key("evil", "evil-bin"),
TrustDecision::Denied {
reason: "no".into(),
},
)
.unwrap_err();
assert!(matches!(err, TrustStoreError::Io { .. }));
assert_eq!(store.len(), 1);
assert!(store.get(&key("evil", "evil-bin")).is_none());
std::fs::remove_file(&lex_dir).unwrap();
std::fs::create_dir_all(&lex_dir).unwrap();
std::fs::write(lex_dir.join("trust.json"), saved_body).unwrap();
}
#[test]
fn unsupported_version_yields_typed_error() {
let dir = tempfile::tempdir().unwrap();
let trust_path = dir.path().join(".lex/trust.json");
std::fs::create_dir_all(trust_path.parent().unwrap()).unwrap();
std::fs::write(&trust_path, r#"{"version": 99, "entries": []}"#).unwrap();
let err = TrustStore::open(dir.path()).unwrap_err();
match err {
TrustStoreError::UnsupportedVersion { version, .. } => assert_eq!(version, 99),
other => panic!("expected UnsupportedVersion, got: {other}"),
}
}
#[test]
fn malformed_json_yields_parse_error() {
let dir = tempfile::tempdir().unwrap();
let trust_path = dir.path().join(".lex/trust.json");
std::fs::create_dir_all(trust_path.parent().unwrap()).unwrap();
std::fs::write(&trust_path, "{not valid json").unwrap();
let err = TrustStore::open(dir.path()).unwrap_err();
assert!(matches!(err, TrustStoreError::Parse { .. }));
}
#[test]
fn disk_format_is_pretty_and_sorted() {
let dir = tempfile::tempdir().unwrap();
{
let mut store = TrustStore::open(dir.path()).unwrap();
store.set(key("zeta", "z"), TrustDecision::Trusted).unwrap();
store
.set(key("alpha", "a"), TrustDecision::Trusted)
.unwrap();
}
let body = std::fs::read_to_string(dir.path().join(".lex/trust.json")).unwrap();
assert!(body.contains('\n'));
assert!(body.find("alpha").unwrap() < body.find("zeta").unwrap());
assert!(body.contains("\"version\": 1"));
}
}