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
10pub const TRUST_STORE_SCHEMA: u32 = 2;
17
18#[derive(Debug, Serialize, Deserialize)]
19pub struct SkillTrustStore {
20 #[serde(default = "schema_v1")]
23 pub schema: u32,
24
25 pub entries: BTreeMap<String, TrustEntry>,
26
27 #[serde(default)]
30 pub revoked: Vec<String>,
31}
32
33fn schema_v1() -> u32 {
34 1
35}
36
37impl Default for SkillTrustStore {
38 fn default() -> Self {
41 Self {
42 schema: TRUST_STORE_SCHEMA,
43 entries: BTreeMap::new(),
44 revoked: Vec::new(),
45 }
46 }
47}
48
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct TrustEntry {
51 pub name: String,
52 pub version: String,
53 pub level: TrustLevel,
54 pub installed_at: String,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub publisher: Option<String>,
57 #[serde(default)]
59 pub content_sha256: String,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub signer_key_fp: Option<String>,
63}
64
65#[derive(Debug)]
66pub enum TrustStoreError {
67 Io(io::Error),
68 Parse(serde_json::Error),
69}
70
71impl std::fmt::Display for TrustStoreError {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 match self {
74 TrustStoreError::Io(e) => write!(f, "io: {e}"),
75 TrustStoreError::Parse(e) => write!(f, "parse: {e}"),
76 }
77 }
78}
79
80impl std::error::Error for TrustStoreError {}
81
82impl From<io::Error> for TrustStoreError {
83 fn from(e: io::Error) -> Self {
84 TrustStoreError::Io(e)
85 }
86}
87
88impl From<serde_json::Error> for TrustStoreError {
89 fn from(e: serde_json::Error) -> Self {
90 TrustStoreError::Parse(e)
91 }
92}
93
94impl SkillTrustStore {
95 pub fn path(mur_home: &Path) -> PathBuf {
96 mur_home.join("trust").join("skills.json")
97 }
98
99 pub fn load(mur_home: &Path) -> Result<Self, TrustStoreError> {
100 let p = Self::path(mur_home);
101 if !p.exists() {
102 return Ok(Self::default());
103 }
104 let s = fs::read_to_string(&p)?;
105 if s.trim().is_empty() {
106 return Ok(Self::default());
107 }
108 Ok(serde_json::from_str(&s)?)
109 }
110
111 pub fn save(&self, mur_home: &Path) -> Result<(), TrustStoreError> {
112 let dir = mur_home.join("trust");
113 fs::create_dir_all(&dir)?;
114 let lock_path = dir.join(".skills.lock");
115 let lock = fs::OpenOptions::new()
116 .read(true)
117 .write(true)
118 .create(true)
119 .truncate(false)
120 .open(&lock_path)?;
121 lock.lock_exclusive()?;
122
123 let result = (|| -> Result<(), TrustStoreError> {
124 let final_path = Self::path(mur_home);
125 let tmp = dir.join(".skills.json.tmp");
126 let json = serde_json::to_string_pretty(self)?;
127 {
128 let mut f = fs::File::create(&tmp)?;
129 f.write_all(json.as_bytes())?;
130 f.sync_all()?;
131 }
132 #[cfg(unix)]
133 {
134 use std::os::unix::fs::PermissionsExt;
135 fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?;
136 }
137 fs::rename(&tmp, &final_path)?;
138 Ok(())
139 })();
140
141 let _ = FileExt::unlock(&lock);
142 let _ = lock;
143 result
144 }
145
146 pub fn insert(&mut self, hash: String, entry: TrustEntry) {
147 self.entries.insert(hash, entry);
148 }
149
150 pub fn lookup(&self, hash: &str) -> Option<&TrustEntry> {
151 if self.is_revoked(hash) {
152 return None;
153 }
154 for (k, v) in &self.entries {
155 if ct_eq_hex(k, hash) {
156 return Some(v);
157 }
158 }
159 None
160 }
161
162 pub fn migrate_to_trust_hash<F>(&mut self, load_manifest: F) -> Option<usize>
186 where
187 F: Fn(&str) -> Option<crate::skill::SkillManifest>,
188 {
189 if self.schema >= TRUST_STORE_SCHEMA {
190 return None;
191 }
192 let is_hash_key = |k: &str| k.len() == 64 && k.chars().all(|c| c.is_ascii_hexdigit());
193
194 let mut rekeyed = 0usize;
195 let mut moved: Vec<(String, String)> = Vec::new();
196 for key in self.entries.keys() {
197 if !is_hash_key(key) {
198 continue; }
200 let Some(entry) = self.entries.get(key) else {
201 continue;
202 };
203 let Some(manifest) = load_manifest(&entry.name) else {
204 continue; };
206 let Ok(new_key) = crate::skill::content_hash_for_trust(&manifest) else {
207 continue;
208 };
209 if new_key != *key {
210 moved.push((key.clone(), new_key));
211 }
212 }
213 for (old, new) in moved {
214 if let Some(entry) = self.entries.remove(&old) {
215 self.entries.insert(new, entry);
216 rekeyed += 1;
217 }
218 }
219 self.schema = TRUST_STORE_SCHEMA;
220 Some(rekeyed)
221 }
222
223 pub fn is_revoked(&self, hash: &str) -> bool {
224 self.revoked.iter().any(|r| ct_eq_hex(r, hash))
225 }
226
227 pub fn revoke(&mut self, hash: &str) {
228 if !self.is_revoked(hash) {
229 self.revoked.push(hash.to_string());
230 }
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use tempfile::tempdir;
238
239 fn entry() -> TrustEntry {
240 TrustEntry {
241 name: "demo".into(),
242 version: "1.0.0".into(),
243 level: TrustLevel::Verified,
244 installed_at: "2026-05-24T00:00:00Z".into(),
245 publisher: Some("human:t".into()),
246 ..Default::default()
247 }
248 }
249
250 fn manifest(name: &str, evolved: bool) -> crate::skill::SkillManifest {
251 let yaml = format!(
252 "name: {name}\nversion: 1.0.0\npublisher: human:t\ndescription: d\ncategory: context\ncontent:\n abstract: a\n"
253 );
254 let mut m = crate::skill::parse_canonical(&yaml).unwrap();
255 if evolved {
256 m.evolution_log
257 .push(crate::skill::evolution::EvolutionEvent::initial_human(
258 "t", "1.0.0",
259 ));
260 }
261 m
262 }
263
264 #[test]
266 fn a_store_without_a_schema_field_is_v1() {
267 let s: SkillTrustStore = serde_json::from_str(r#"{"entries":{},"revoked":[]}"#).unwrap();
268 assert_eq!(s.schema, 1);
269 assert_eq!(SkillTrustStore::default().schema, TRUST_STORE_SCHEMA);
271 }
272
273 #[test]
277 fn migration_leaves_name_keyed_entries_alone() {
278 let m = manifest("demo", true);
279 let mut s = SkillTrustStore {
280 schema: 1,
281 ..Default::default()
282 };
283 s.entries.insert("demo".into(), entry());
284
285 let moved = s.migrate_to_trust_hash(|_| Some(m.clone()));
286
287 assert_eq!(moved, Some(0), "a name key is not a hash key");
288 assert!(s.entries.contains_key("demo"));
289 assert_eq!(s.schema, TRUST_STORE_SCHEMA);
290 }
291
292 #[test]
295 fn migration_keeps_an_entry_whose_skill_is_no_longer_installed() {
296 let legacy = "a".repeat(64);
297 let mut s = SkillTrustStore {
298 schema: 1,
299 ..Default::default()
300 };
301 s.entries.insert(legacy.clone(), entry());
302
303 let moved = s.migrate_to_trust_hash(|_| None);
304
305 assert_eq!(moved, Some(0));
306 assert!(s.entries.contains_key(&legacy), "trust decision was lost");
307 }
308
309 #[test]
311 fn migration_rekeys_a_hash_entry_into_the_trust_domain() {
312 let m = manifest("demo", true);
313 let legacy = crate::skill::content_sha256(&m).unwrap();
314 let target = crate::skill::content_hash_for_trust(&m).unwrap();
315 assert_ne!(legacy, target, "precondition: domains must differ");
316
317 let mut s = SkillTrustStore {
318 schema: 1,
319 ..Default::default()
320 };
321 s.entries.insert(legacy.clone(), entry());
322
323 let moved = s.migrate_to_trust_hash(|_| Some(m.clone()));
324
325 assert_eq!(moved, Some(1));
326 assert!(!s.entries.contains_key(&legacy));
327 assert_eq!(s.entries.get(&target).unwrap().level, TrustLevel::Verified);
328 }
329
330 #[test]
332 fn migration_is_a_no_op_on_a_current_store() {
333 let m = manifest("demo", true);
334 let mut s = SkillTrustStore::default();
335 let legacy = crate::skill::content_sha256(&m).unwrap();
336 s.entries.insert(legacy.clone(), entry());
337
338 assert_eq!(s.migrate_to_trust_hash(|_| Some(m.clone())), None);
339 assert!(
340 s.entries.contains_key(&legacy),
341 "a v2 store must not be re-keyed again"
342 );
343 }
344
345 #[test]
346 fn insert_lookup_save_load_roundtrip() {
347 let dir = tempdir().unwrap();
348 let mut s = SkillTrustStore::default();
349 s.insert("a".repeat(64), entry());
350 s.save(dir.path()).unwrap();
351 let s2 = SkillTrustStore::load(dir.path()).unwrap();
352 assert_eq!(s2.entries.len(), 1);
353 assert_eq!(s2.lookup(&"a".repeat(64)).unwrap().name, "demo");
354 }
355
356 #[test]
357 fn revoked_hash_returns_none() {
358 let mut s = SkillTrustStore::default();
359 let h = "b".repeat(64);
360 s.insert(h.clone(), entry());
361 s.revoke(&h);
362 assert!(s.lookup(&h).is_none());
363 assert!(s.is_revoked(&h));
364 }
365
366 #[test]
367 fn missing_file_loads_empty() {
368 let dir = tempdir().unwrap();
369 let s = SkillTrustStore::load(dir.path()).unwrap();
370 assert!(s.entries.is_empty());
371 }
372
373 #[cfg(unix)]
374 #[test]
375 fn saved_file_is_0600() {
376 use std::os::unix::fs::PermissionsExt;
377 let dir = tempdir().unwrap();
378 let s = SkillTrustStore::default();
379 s.save(dir.path()).unwrap();
380 let mode = fs::metadata(SkillTrustStore::path(dir.path()))
381 .unwrap()
382 .permissions()
383 .mode()
384 & 0o777;
385 assert_eq!(mode, 0o600);
386 }
387
388 #[test]
389 fn revoke_is_idempotent() {
390 let mut s = SkillTrustStore::default();
391 s.revoke("c".repeat(64).as_str());
392 s.revoke("c".repeat(64).as_str());
393 assert_eq!(s.revoked.len(), 1);
394 }
395}