mur_common/trust/
skills.rs1use 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 #[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 #[serde(default)]
30 pub content_sha256: String,
31 #[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}