Skip to main content

mur_common/trust/
skills.rs

1use crate::skill::ct_eq_hex;
2use crate::skill::types::TrustLevel;
3use fs2::FileExt;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6use std::fs;
7use std::io::{self, Write};
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Default, Serialize, Deserialize)]
11pub struct SkillTrustStore {
12    pub entries: BTreeMap<String, TrustEntry>,
13
14    /// Kill-switch — content hashes that may NEVER load, regardless of
15    /// the per-entry trust level.
16    #[serde(default)]
17    pub revoked: Vec<String>,
18}
19
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21pub struct TrustEntry {
22    pub name: String,
23    pub version: String,
24    pub level: TrustLevel,
25    pub installed_at: String,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub publisher: Option<String>,
28    /// SHA-256 hex of the installed skill YAML; used for rug-pull detection.
29    #[serde(default)]
30    pub content_sha256: String,
31    /// Key fingerprint of the signer at install time; used for publisher-change detection.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub signer_key_fp: Option<String>,
34}
35
36#[derive(Debug)]
37pub enum TrustStoreError {
38    Io(io::Error),
39    Parse(serde_json::Error),
40}
41
42impl std::fmt::Display for TrustStoreError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            TrustStoreError::Io(e) => write!(f, "io: {e}"),
46            TrustStoreError::Parse(e) => write!(f, "parse: {e}"),
47        }
48    }
49}
50
51impl std::error::Error for TrustStoreError {}
52
53impl From<io::Error> for TrustStoreError {
54    fn from(e: io::Error) -> Self {
55        TrustStoreError::Io(e)
56    }
57}
58
59impl From<serde_json::Error> for TrustStoreError {
60    fn from(e: serde_json::Error) -> Self {
61        TrustStoreError::Parse(e)
62    }
63}
64
65impl SkillTrustStore {
66    pub fn path(mur_home: &Path) -> PathBuf {
67        mur_home.join("trust").join("skills.json")
68    }
69
70    pub fn load(mur_home: &Path) -> Result<Self, TrustStoreError> {
71        let p = Self::path(mur_home);
72        if !p.exists() {
73            return Ok(Self::default());
74        }
75        let s = fs::read_to_string(&p)?;
76        if s.trim().is_empty() {
77            return Ok(Self::default());
78        }
79        Ok(serde_json::from_str(&s)?)
80    }
81
82    pub fn save(&self, mur_home: &Path) -> Result<(), TrustStoreError> {
83        let dir = mur_home.join("trust");
84        fs::create_dir_all(&dir)?;
85        let lock_path = dir.join(".skills.lock");
86        let lock = fs::OpenOptions::new()
87            .read(true)
88            .write(true)
89            .create(true)
90            .truncate(false)
91            .open(&lock_path)?;
92        lock.lock_exclusive()?;
93
94        let result = (|| -> Result<(), TrustStoreError> {
95            let final_path = Self::path(mur_home);
96            let tmp = dir.join(".skills.json.tmp");
97            let json = serde_json::to_string_pretty(self)?;
98            {
99                let mut f = fs::File::create(&tmp)?;
100                f.write_all(json.as_bytes())?;
101                f.sync_all()?;
102            }
103            #[cfg(unix)]
104            {
105                use std::os::unix::fs::PermissionsExt;
106                fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?;
107            }
108            fs::rename(&tmp, &final_path)?;
109            Ok(())
110        })();
111
112        let _ = FileExt::unlock(&lock);
113        let _ = lock;
114        result
115    }
116
117    pub fn insert(&mut self, hash: String, entry: TrustEntry) {
118        self.entries.insert(hash, entry);
119    }
120
121    pub fn lookup(&self, hash: &str) -> Option<&TrustEntry> {
122        if self.is_revoked(hash) {
123            return None;
124        }
125        for (k, v) in &self.entries {
126            if ct_eq_hex(k, hash) {
127                return Some(v);
128            }
129        }
130        None
131    }
132
133    pub fn is_revoked(&self, hash: &str) -> bool {
134        self.revoked.iter().any(|r| ct_eq_hex(r, hash))
135    }
136
137    pub fn revoke(&mut self, hash: &str) {
138        if !self.is_revoked(hash) {
139            self.revoked.push(hash.to_string());
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use tempfile::tempdir;
148
149    fn entry() -> TrustEntry {
150        TrustEntry {
151            name: "demo".into(),
152            version: "1.0.0".into(),
153            level: TrustLevel::Verified,
154            installed_at: "2026-05-24T00:00:00Z".into(),
155            publisher: Some("human:t".into()),
156            ..Default::default()
157        }
158    }
159
160    #[test]
161    fn insert_lookup_save_load_roundtrip() {
162        let dir = tempdir().unwrap();
163        let mut s = SkillTrustStore::default();
164        s.insert("a".repeat(64), entry());
165        s.save(dir.path()).unwrap();
166        let s2 = SkillTrustStore::load(dir.path()).unwrap();
167        assert_eq!(s2.entries.len(), 1);
168        assert_eq!(s2.lookup(&"a".repeat(64)).unwrap().name, "demo");
169    }
170
171    #[test]
172    fn revoked_hash_returns_none() {
173        let mut s = SkillTrustStore::default();
174        let h = "b".repeat(64);
175        s.insert(h.clone(), entry());
176        s.revoke(&h);
177        assert!(s.lookup(&h).is_none());
178        assert!(s.is_revoked(&h));
179    }
180
181    #[test]
182    fn missing_file_loads_empty() {
183        let dir = tempdir().unwrap();
184        let s = SkillTrustStore::load(dir.path()).unwrap();
185        assert!(s.entries.is_empty());
186    }
187
188    #[cfg(unix)]
189    #[test]
190    fn saved_file_is_0600() {
191        use std::os::unix::fs::PermissionsExt;
192        let dir = tempdir().unwrap();
193        let s = SkillTrustStore::default();
194        s.save(dir.path()).unwrap();
195        let mode = fs::metadata(SkillTrustStore::path(dir.path()))
196            .unwrap()
197            .permissions()
198            .mode()
199            & 0o777;
200        assert_eq!(mode, 0o600);
201    }
202
203    #[test]
204    fn revoke_is_idempotent() {
205        let mut s = SkillTrustStore::default();
206        s.revoke("c".repeat(64).as_str());
207        s.revoke("c".repeat(64).as_str());
208        assert_eq!(s.revoked.len(), 1);
209    }
210}