use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
const TRUST_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TrustState {
Acknowledged,
Denied,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrustAuditEntry {
pub at: String,
pub action: String,
pub by: String,
pub fingerprint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrustRecord {
pub state: TrustState,
pub fingerprint: String,
pub by: String,
pub at: String,
#[serde(default)]
pub audit: Vec<TrustAuditEntry>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TrustFile {
#[serde(default)]
version: u32,
#[serde(default)]
servers: HashMap<String, TrustRecord>,
}
#[derive(Debug)]
pub struct TrustStore {
path: PathBuf,
servers: HashMap<String, TrustRecord>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustDecision {
Acknowledged,
Pending,
Denied,
}
impl TrustStore {
pub fn load(path: &Path) -> Result<Self> {
let servers = match std::fs::read_to_string(path) {
Ok(content) => {
let file: TrustFile = serde_json::from_str(&content).map_err(|err| {
Error::tool(
"mcp",
format!(
"[MCP_TRUST_CORRUPT] {} is not valid: {err}; \
delete it to reset all MCP trust decisions",
path.display()
),
)
})?;
file.servers
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => HashMap::new(),
Err(err) => {
return Err(Error::tool(
"mcp",
format!("[MCP_TRUST_IO] cannot read {}: {err}", path.display()),
));
}
};
Ok(Self {
path: path.to_path_buf(),
servers,
})
}
#[must_use]
pub fn decision(&self, name: &str, fingerprint: &str) -> TrustDecision {
match self.servers.get(name) {
Some(record) if record.fingerprint == fingerprint => match record.state {
TrustState::Acknowledged => TrustDecision::Acknowledged,
TrustState::Denied => TrustDecision::Denied,
},
_ => TrustDecision::Pending,
}
}
pub fn acknowledge(&mut self, name: &str, fingerprint: &str, by: &str) -> Result<()> {
self.transition(
name,
fingerprint,
TrustState::Acknowledged,
by,
"acknowledged",
)
}
pub fn deny(&mut self, name: &str, fingerprint: &str, by: &str) -> Result<()> {
self.transition(name, fingerprint, TrustState::Denied, by, "denied")
}
pub fn reset(&mut self, name: &str, by: &str) -> Result<()> {
if let Some(record) = self.servers.get_mut(name) {
record.audit.push(TrustAuditEntry {
at: now_iso(),
action: "reset".to_string(),
by: by.to_string(),
fingerprint: record.fingerprint.clone(),
});
}
self.servers.remove(name);
self.save()
}
fn transition(
&mut self,
name: &str,
fingerprint: &str,
state: TrustState,
by: &str,
action: &str,
) -> Result<()> {
let at = now_iso();
let audit = TrustAuditEntry {
at: at.clone(),
action: action.to_string(),
by: by.to_string(),
fingerprint: fingerprint.to_string(),
};
let record = self
.servers
.entry(name.to_string())
.or_insert_with(|| TrustRecord {
state,
fingerprint: fingerprint.to_string(),
by: by.to_string(),
at: at.clone(),
audit: Vec::new(),
});
record.state = state;
record.fingerprint = fingerprint.to_string();
record.by = by.to_string();
record.at = at;
record.audit.push(audit);
self.save()
}
fn save(&self) -> Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
Error::tool(
"mcp",
format!("[MCP_TRUST_IO] cannot create {}: {err}", parent.display()),
)
})?;
}
let file = TrustFile {
version: TRUST_SCHEMA_VERSION,
servers: self.servers.clone(),
};
let rendered = serde_json::to_string_pretty(&file)
.map_err(|err| Error::tool("mcp", format!("[MCP_TRUST_IO] serialize failed: {err}")))?;
let mut temp =
tempfile::NamedTempFile::new_in(self.path.parent().unwrap_or_else(|| Path::new(".")))
.map_err(|err| Error::tool("mcp", format!("[MCP_TRUST_IO] temp file: {err}")))?;
std::io::Write::write_all(&mut temp, rendered.as_bytes())
.map_err(|err| Error::tool("mcp", format!("[MCP_TRUST_IO] write: {err}")))?;
temp.persist(&self.path)
.map_err(|err| Error::tool("mcp", format!("[MCP_TRUST_IO] persist: {}", err.error)))?;
Ok(())
}
#[must_use]
pub const fn records(&self) -> &HashMap<String, TrustRecord> {
&self.servers
}
}
fn now_iso() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pending_by_default_and_after_fingerprint_change() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("trust.json");
let mut store = TrustStore::load(&path).expect("load");
assert_eq!(store.decision("srv", "fp1"), TrustDecision::Pending);
store.acknowledge("srv", "fp1", "operator").expect("ack");
assert_eq!(store.decision("srv", "fp1"), TrustDecision::Acknowledged);
assert_eq!(store.decision("srv", "fp2"), TrustDecision::Pending);
}
#[test]
fn denial_is_fail_closed_and_sticky() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("trust.json");
let mut store = TrustStore::load(&path).expect("load");
store.deny("srv", "fp1", "operator").expect("deny");
assert_eq!(store.decision("srv", "fp1"), TrustDecision::Denied);
store.acknowledge("srv", "fp1", "operator").expect("ack");
assert_eq!(store.decision("srv", "fp1"), TrustDecision::Acknowledged);
}
#[test]
fn persistence_roundtrip_with_audit() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("trust.json");
{
let mut store = TrustStore::load(&path).expect("load");
store.acknowledge("srv", "fp1", "operator").expect("ack");
store.deny("srv", "fp2", "operator").expect("deny");
}
let store = TrustStore::load(&path).expect("reload");
assert_eq!(store.decision("srv", "fp2"), TrustDecision::Denied);
let record = &store.records()["srv"]; assert_eq!(record.audit.len(), 2);
assert_eq!(record.audit[0].action, "acknowledged");
assert_eq!(record.audit[1].action, "denied");
assert!(record.audit.iter().all(|a| a.by == "operator"));
}
#[test]
fn reset_re_pends() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("trust.json");
let mut store = TrustStore::load(&path).expect("load");
store.acknowledge("srv", "fp1", "operator").expect("ack");
store.reset("srv", "operator").expect("reset");
assert_eq!(store.decision("srv", "fp1"), TrustDecision::Pending);
}
#[test]
fn corrupt_store_fails_closed() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("trust.json");
std::fs::write(&path, "{not json").expect("write");
let err = TrustStore::load(&path).expect_err("corrupt must fail");
assert!(err.to_string().contains("MCP_TRUST_CORRUPT"), "{err}");
}
}