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