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, 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}