use crate::skill::ct_eq_hex;
use crate::skill::types::TrustLevel;
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
pub const TRUST_STORE_SCHEMA: u32 = 2;
#[derive(Debug, Serialize, Deserialize)]
pub struct SkillTrustStore {
#[serde(default = "schema_v1")]
pub schema: u32,
pub entries: BTreeMap<String, TrustEntry>,
#[serde(default)]
pub revoked: Vec<String>,
}
fn schema_v1() -> u32 {
1
}
impl Default for SkillTrustStore {
fn default() -> Self {
Self {
schema: TRUST_STORE_SCHEMA,
entries: BTreeMap::new(),
revoked: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TrustEntry {
pub name: String,
pub version: String,
pub level: TrustLevel,
pub installed_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher: Option<String>,
#[serde(default)]
pub content_sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signer_key_fp: Option<String>,
}
#[derive(Debug)]
pub enum TrustStoreError {
Io(io::Error),
Parse(serde_json::Error),
}
impl std::fmt::Display for TrustStoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TrustStoreError::Io(e) => write!(f, "io: {e}"),
TrustStoreError::Parse(e) => write!(f, "parse: {e}"),
}
}
}
impl std::error::Error for TrustStoreError {}
impl From<io::Error> for TrustStoreError {
fn from(e: io::Error) -> Self {
TrustStoreError::Io(e)
}
}
impl From<serde_json::Error> for TrustStoreError {
fn from(e: serde_json::Error) -> Self {
TrustStoreError::Parse(e)
}
}
impl SkillTrustStore {
pub fn path(mur_home: &Path) -> PathBuf {
mur_home.join("trust").join("skills.json")
}
pub fn load(mur_home: &Path) -> Result<Self, TrustStoreError> {
let p = Self::path(mur_home);
if !p.exists() {
return Ok(Self::default());
}
let s = fs::read_to_string(&p)?;
if s.trim().is_empty() {
return Ok(Self::default());
}
Ok(serde_json::from_str(&s)?)
}
pub fn save(&self, mur_home: &Path) -> Result<(), TrustStoreError> {
let dir = mur_home.join("trust");
fs::create_dir_all(&dir)?;
let lock_path = dir.join(".skills.lock");
let lock = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)?;
lock.lock_exclusive()?;
let result = (|| -> Result<(), TrustStoreError> {
let final_path = Self::path(mur_home);
let tmp = dir.join(".skills.json.tmp");
let json = serde_json::to_string_pretty(self)?;
{
let mut f = fs::File::create(&tmp)?;
f.write_all(json.as_bytes())?;
f.sync_all()?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?;
}
fs::rename(&tmp, &final_path)?;
Ok(())
})();
let _ = FileExt::unlock(&lock);
let _ = lock;
result
}
pub fn insert(&mut self, hash: String, entry: TrustEntry) {
self.entries.insert(hash, entry);
}
pub fn lookup(&self, hash: &str) -> Option<&TrustEntry> {
if self.is_revoked(hash) {
return None;
}
for (k, v) in &self.entries {
if ct_eq_hex(k, hash) {
return Some(v);
}
}
None
}
pub fn migrate_to_trust_hash<F>(&mut self, load_manifest: F) -> Option<usize>
where
F: Fn(&str) -> Option<crate::skill::SkillManifest>,
{
if self.schema >= TRUST_STORE_SCHEMA {
return None;
}
let is_hash_key = |k: &str| k.len() == 64 && k.chars().all(|c| c.is_ascii_hexdigit());
let mut rekeyed = 0usize;
let mut moved: Vec<(String, String)> = Vec::new();
for key in self.entries.keys() {
if !is_hash_key(key) {
continue; }
let Some(entry) = self.entries.get(key) else {
continue;
};
let Some(manifest) = load_manifest(&entry.name) else {
continue; };
let Ok(new_key) = crate::skill::content_hash_for_trust(&manifest) else {
continue;
};
if new_key != *key {
moved.push((key.clone(), new_key));
}
}
for (old, new) in moved {
if let Some(entry) = self.entries.remove(&old) {
self.entries.insert(new, entry);
rekeyed += 1;
}
}
self.schema = TRUST_STORE_SCHEMA;
Some(rekeyed)
}
pub fn is_revoked(&self, hash: &str) -> bool {
self.revoked.iter().any(|r| ct_eq_hex(r, hash))
}
pub fn revoke(&mut self, hash: &str) {
if !self.is_revoked(hash) {
self.revoked.push(hash.to_string());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn entry() -> TrustEntry {
TrustEntry {
name: "demo".into(),
version: "1.0.0".into(),
level: TrustLevel::Verified,
installed_at: "2026-05-24T00:00:00Z".into(),
publisher: Some("human:t".into()),
..Default::default()
}
}
fn manifest(name: &str, evolved: bool) -> crate::skill::SkillManifest {
let yaml = format!(
"name: {name}\nversion: 1.0.0\npublisher: human:t\ndescription: d\ncategory: context\ncontent:\n abstract: a\n"
);
let mut m = crate::skill::parse_canonical(&yaml).unwrap();
if evolved {
m.evolution_log
.push(crate::skill::evolution::EvolutionEvent::initial_human(
"t", "1.0.0",
));
}
m
}
#[test]
fn a_store_without_a_schema_field_is_v1() {
let s: SkillTrustStore = serde_json::from_str(r#"{"entries":{},"revoked":[]}"#).unwrap();
assert_eq!(s.schema, 1);
assert_eq!(SkillTrustStore::default().schema, TRUST_STORE_SCHEMA);
}
#[test]
fn migration_leaves_name_keyed_entries_alone() {
let m = manifest("demo", true);
let mut s = SkillTrustStore {
schema: 1,
..Default::default()
};
s.entries.insert("demo".into(), entry());
let moved = s.migrate_to_trust_hash(|_| Some(m.clone()));
assert_eq!(moved, Some(0), "a name key is not a hash key");
assert!(s.entries.contains_key("demo"));
assert_eq!(s.schema, TRUST_STORE_SCHEMA);
}
#[test]
fn migration_keeps_an_entry_whose_skill_is_no_longer_installed() {
let legacy = "a".repeat(64);
let mut s = SkillTrustStore {
schema: 1,
..Default::default()
};
s.entries.insert(legacy.clone(), entry());
let moved = s.migrate_to_trust_hash(|_| None);
assert_eq!(moved, Some(0));
assert!(s.entries.contains_key(&legacy), "trust decision was lost");
}
#[test]
fn migration_rekeys_a_hash_entry_into_the_trust_domain() {
let m = manifest("demo", true);
let legacy = crate::skill::content_sha256(&m).unwrap();
let target = crate::skill::content_hash_for_trust(&m).unwrap();
assert_ne!(legacy, target, "precondition: domains must differ");
let mut s = SkillTrustStore {
schema: 1,
..Default::default()
};
s.entries.insert(legacy.clone(), entry());
let moved = s.migrate_to_trust_hash(|_| Some(m.clone()));
assert_eq!(moved, Some(1));
assert!(!s.entries.contains_key(&legacy));
assert_eq!(s.entries.get(&target).unwrap().level, TrustLevel::Verified);
}
#[test]
fn migration_is_a_no_op_on_a_current_store() {
let m = manifest("demo", true);
let mut s = SkillTrustStore::default();
let legacy = crate::skill::content_sha256(&m).unwrap();
s.entries.insert(legacy.clone(), entry());
assert_eq!(s.migrate_to_trust_hash(|_| Some(m.clone())), None);
assert!(
s.entries.contains_key(&legacy),
"a v2 store must not be re-keyed again"
);
}
#[test]
fn insert_lookup_save_load_roundtrip() {
let dir = tempdir().unwrap();
let mut s = SkillTrustStore::default();
s.insert("a".repeat(64), entry());
s.save(dir.path()).unwrap();
let s2 = SkillTrustStore::load(dir.path()).unwrap();
assert_eq!(s2.entries.len(), 1);
assert_eq!(s2.lookup(&"a".repeat(64)).unwrap().name, "demo");
}
#[test]
fn revoked_hash_returns_none() {
let mut s = SkillTrustStore::default();
let h = "b".repeat(64);
s.insert(h.clone(), entry());
s.revoke(&h);
assert!(s.lookup(&h).is_none());
assert!(s.is_revoked(&h));
}
#[test]
fn missing_file_loads_empty() {
let dir = tempdir().unwrap();
let s = SkillTrustStore::load(dir.path()).unwrap();
assert!(s.entries.is_empty());
}
#[cfg(unix)]
#[test]
fn saved_file_is_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let s = SkillTrustStore::default();
s.save(dir.path()).unwrap();
let mode = fs::metadata(SkillTrustStore::path(dir.path()))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn revoke_is_idempotent() {
let mut s = SkillTrustStore::default();
s.revoke("c".repeat(64).as_str());
s.revoke("c".repeat(64).as_str());
assert_eq!(s.revoked.len(), 1);
}
}