Skip to main content

zeph_memory/store/
trust.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use serde::{Deserialize, Serialize};
5#[allow(unused_imports)]
6use zeph_db::sql;
7
8use super::SqliteStore;
9use crate::error::MemoryError;
10
11/// Discriminant for the skill source stored in the trust table.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum SourceKind {
15    Local,
16    Hub,
17    File,
18}
19
20impl SourceKind {
21    fn as_str(&self) -> &'static str {
22        match self {
23            Self::Local => "local",
24            Self::Hub => "hub",
25            Self::File => "file",
26        }
27    }
28}
29
30impl std::fmt::Display for SourceKind {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        f.write_str(self.as_str())
33    }
34}
35
36impl std::str::FromStr for SourceKind {
37    type Err = String;
38
39    fn from_str(s: &str) -> Result<Self, Self::Err> {
40        match s {
41            "local" => Ok(Self::Local),
42            "hub" => Ok(Self::Hub),
43            "file" => Ok(Self::File),
44            other => Err(format!("unknown source_kind: {other}")),
45        }
46    }
47}
48
49#[derive(Debug, Clone)]
50pub struct SkillTrustRow {
51    pub skill_name: String,
52    pub trust_level: String,
53    pub source_kind: SourceKind,
54    pub source_url: Option<String>,
55    pub source_path: Option<String>,
56    pub blake3_hash: String,
57    pub updated_at: String,
58    /// Upstream git commit hash at install time (from `x-git-hash` frontmatter field).
59    pub git_hash: Option<String>,
60}
61
62type TrustTuple = (
63    String,
64    String,
65    String,
66    Option<String>,
67    Option<String>,
68    String,
69    String,
70    Option<String>,
71);
72
73fn row_from_tuple(t: TrustTuple) -> SkillTrustRow {
74    let source_kind = t.2.parse::<SourceKind>().unwrap_or(SourceKind::Local);
75    SkillTrustRow {
76        skill_name: t.0,
77        trust_level: t.1,
78        source_kind,
79        source_url: t.3,
80        source_path: t.4,
81        blake3_hash: t.5,
82        updated_at: t.6,
83        git_hash: t.7,
84    }
85}
86
87impl SqliteStore {
88    /// Upsert trust metadata for a skill.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if the database operation fails.
93    pub async fn upsert_skill_trust(
94        &self,
95        skill_name: &str,
96        trust_level: &str,
97        source_kind: SourceKind,
98        source_url: Option<&str>,
99        source_path: Option<&str>,
100        blake3_hash: &str,
101    ) -> Result<(), MemoryError> {
102        self.upsert_skill_trust_with_git_hash(
103            skill_name,
104            trust_level,
105            source_kind,
106            source_url,
107            source_path,
108            blake3_hash,
109            None,
110        )
111        .await
112    }
113
114    /// Upsert trust metadata for a skill, including an optional upstream git hash.
115    ///
116    /// `git_hash` is the upstream commit hash from the `x-git-hash` SKILL.md frontmatter field.
117    /// It tracks the upstream commit at install time and is stored separately from `blake3_hash`
118    /// (which tracks content integrity).
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if the database operation fails.
123    #[allow(clippy::too_many_arguments)]
124    pub async fn upsert_skill_trust_with_git_hash(
125        &self,
126        skill_name: &str,
127        trust_level: &str,
128        source_kind: SourceKind,
129        source_url: Option<&str>,
130        source_path: Option<&str>,
131        blake3_hash: &str,
132        git_hash: Option<&str>,
133    ) -> Result<(), MemoryError> {
134        zeph_db::query(
135            sql!("INSERT INTO skill_trust \
136             (skill_name, trust_level, source_kind, source_url, source_path, blake3_hash, git_hash, updated_at) \
137             VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) \
138             ON CONFLICT(skill_name) DO UPDATE SET \
139             trust_level = excluded.trust_level, \
140             source_kind = excluded.source_kind, \
141             source_url = excluded.source_url, \
142             source_path = excluded.source_path, \
143             blake3_hash = excluded.blake3_hash, \
144             git_hash = excluded.git_hash, \
145             updated_at = CURRENT_TIMESTAMP"),
146        )
147        .bind(skill_name)
148        .bind(trust_level)
149        .bind(source_kind.as_str())
150        .bind(source_url)
151        .bind(source_path)
152        .bind(blake3_hash)
153        .bind(git_hash)
154        .execute(&self.pool)
155        .await?;
156        Ok(())
157    }
158
159    /// Load trust metadata for a single skill.
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if the query fails.
164    pub async fn load_skill_trust(
165        &self,
166        skill_name: &str,
167    ) -> Result<Option<SkillTrustRow>, MemoryError> {
168        let row: Option<TrustTuple> = zeph_db::query_as(sql!(
169            "SELECT skill_name, trust_level, source_kind, source_url, source_path, \
170             blake3_hash, updated_at, git_hash \
171             FROM skill_trust WHERE skill_name = ?"
172        ))
173        .bind(skill_name)
174        .fetch_optional(&self.pool)
175        .await?;
176        Ok(row.map(row_from_tuple))
177    }
178
179    /// Load all skill trust entries.
180    ///
181    /// # Errors
182    ///
183    /// Returns an error if the query fails.
184    pub async fn load_all_skill_trust(&self) -> Result<Vec<SkillTrustRow>, MemoryError> {
185        let rows: Vec<TrustTuple> = zeph_db::query_as(sql!(
186            "SELECT skill_name, trust_level, source_kind, source_url, source_path, \
187             blake3_hash, updated_at, git_hash \
188             FROM skill_trust ORDER BY skill_name"
189        ))
190        .fetch_all(&self.pool)
191        .await?;
192        Ok(rows.into_iter().map(row_from_tuple).collect())
193    }
194
195    /// Update only the trust level for a skill.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error if the skill does not exist or the update fails.
200    pub async fn set_skill_trust_level(
201        &self,
202        skill_name: &str,
203        trust_level: &str,
204    ) -> Result<bool, MemoryError> {
205        let result = zeph_db::query(
206            sql!("UPDATE skill_trust SET trust_level = ?, updated_at = CURRENT_TIMESTAMP WHERE skill_name = ?"),
207        )
208        .bind(trust_level)
209        .bind(skill_name)
210        .execute(&self.pool)
211        .await?;
212        Ok(result.rows_affected() > 0)
213    }
214
215    /// Delete trust entry for a skill.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if the delete fails.
220    pub async fn delete_skill_trust(&self, skill_name: &str) -> Result<bool, MemoryError> {
221        let result = zeph_db::query(sql!("DELETE FROM skill_trust WHERE skill_name = ?"))
222            .bind(skill_name)
223            .execute(&self.pool)
224            .await?;
225        Ok(result.rows_affected() > 0)
226    }
227
228    /// Update the blake3 hash for a skill.
229    ///
230    /// # Errors
231    ///
232    /// Returns an error if the update fails.
233    pub async fn update_skill_hash(
234        &self,
235        skill_name: &str,
236        blake3_hash: &str,
237    ) -> Result<bool, MemoryError> {
238        let result = zeph_db::query(
239            sql!("UPDATE skill_trust SET blake3_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE skill_name = ?"),
240        )
241        .bind(blake3_hash)
242        .bind(skill_name)
243        .execute(&self.pool)
244        .await?;
245        Ok(result.rows_affected() > 0)
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    async fn test_store() -> SqliteStore {
254        SqliteStore::new(":memory:").await.unwrap()
255    }
256
257    #[tokio::test]
258    async fn upsert_and_load() {
259        let store = test_store().await;
260
261        store
262            .upsert_skill_trust("git", "trusted", SourceKind::Local, None, None, "abc123")
263            .await
264            .unwrap();
265
266        let row = store.load_skill_trust("git").await.unwrap().unwrap();
267        assert_eq!(row.skill_name, "git");
268        assert_eq!(row.trust_level, "trusted");
269        assert_eq!(row.source_kind, SourceKind::Local);
270        assert_eq!(row.blake3_hash, "abc123");
271    }
272
273    #[tokio::test]
274    async fn upsert_updates_existing() {
275        let store = test_store().await;
276
277        store
278            .upsert_skill_trust("git", "quarantined", SourceKind::Local, None, None, "hash1")
279            .await
280            .unwrap();
281        store
282            .upsert_skill_trust("git", "trusted", SourceKind::Local, None, None, "hash2")
283            .await
284            .unwrap();
285
286        let row = store.load_skill_trust("git").await.unwrap().unwrap();
287        assert_eq!(row.trust_level, "trusted");
288        assert_eq!(row.blake3_hash, "hash2");
289    }
290
291    #[tokio::test]
292    async fn load_nonexistent() {
293        let store = test_store().await;
294        let row = store.load_skill_trust("nope").await.unwrap();
295        assert!(row.is_none());
296    }
297
298    #[tokio::test]
299    async fn load_all() {
300        let store = test_store().await;
301
302        store
303            .upsert_skill_trust("alpha", "trusted", SourceKind::Local, None, None, "h1")
304            .await
305            .unwrap();
306        store
307            .upsert_skill_trust(
308                "beta",
309                "quarantined",
310                SourceKind::Hub,
311                Some("https://hub.example.com"),
312                None,
313                "h2",
314            )
315            .await
316            .unwrap();
317
318        let rows = store.load_all_skill_trust().await.unwrap();
319        assert_eq!(rows.len(), 2);
320        assert_eq!(rows[0].skill_name, "alpha");
321        assert_eq!(rows[1].skill_name, "beta");
322    }
323
324    #[tokio::test]
325    async fn set_trust_level() {
326        let store = test_store().await;
327
328        store
329            .upsert_skill_trust("git", "quarantined", SourceKind::Local, None, None, "h1")
330            .await
331            .unwrap();
332
333        let updated = store.set_skill_trust_level("git", "blocked").await.unwrap();
334        assert!(updated);
335
336        let row = store.load_skill_trust("git").await.unwrap().unwrap();
337        assert_eq!(row.trust_level, "blocked");
338    }
339
340    #[tokio::test]
341    async fn set_trust_level_nonexistent() {
342        let store = test_store().await;
343        let updated = store
344            .set_skill_trust_level("nope", "blocked")
345            .await
346            .unwrap();
347        assert!(!updated);
348    }
349
350    #[tokio::test]
351    async fn delete_trust() {
352        let store = test_store().await;
353
354        store
355            .upsert_skill_trust("git", "trusted", SourceKind::Local, None, None, "h1")
356            .await
357            .unwrap();
358
359        let deleted = store.delete_skill_trust("git").await.unwrap();
360        assert!(deleted);
361
362        let row = store.load_skill_trust("git").await.unwrap();
363        assert!(row.is_none());
364    }
365
366    #[tokio::test]
367    async fn delete_nonexistent() {
368        let store = test_store().await;
369        let deleted = store.delete_skill_trust("nope").await.unwrap();
370        assert!(!deleted);
371    }
372
373    #[tokio::test]
374    async fn update_hash() {
375        let store = test_store().await;
376
377        store
378            .upsert_skill_trust("git", "verified", SourceKind::Local, None, None, "old_hash")
379            .await
380            .unwrap();
381
382        let updated = store.update_skill_hash("git", "new_hash").await.unwrap();
383        assert!(updated);
384
385        let row = store.load_skill_trust("git").await.unwrap().unwrap();
386        assert_eq!(row.blake3_hash, "new_hash");
387    }
388
389    #[tokio::test]
390    async fn source_with_url() {
391        let store = test_store().await;
392
393        store
394            .upsert_skill_trust(
395                "remote-skill",
396                "quarantined",
397                SourceKind::Hub,
398                Some("https://hub.example.com/skill"),
399                None,
400                "h1",
401            )
402            .await
403            .unwrap();
404
405        let row = store
406            .load_skill_trust("remote-skill")
407            .await
408            .unwrap()
409            .unwrap();
410        assert_eq!(row.source_kind, SourceKind::Hub);
411        assert_eq!(
412            row.source_url.as_deref(),
413            Some("https://hub.example.com/skill")
414        );
415    }
416
417    #[tokio::test]
418    async fn source_with_path() {
419        let store = test_store().await;
420
421        store
422            .upsert_skill_trust(
423                "file-skill",
424                "quarantined",
425                SourceKind::File,
426                None,
427                Some("/tmp/skill.tar.gz"),
428                "h1",
429            )
430            .await
431            .unwrap();
432
433        let row = store.load_skill_trust("file-skill").await.unwrap().unwrap();
434        assert_eq!(row.source_kind, SourceKind::File);
435        assert_eq!(row.source_path.as_deref(), Some("/tmp/skill.tar.gz"));
436    }
437
438    #[test]
439    fn source_kind_display_local() {
440        assert_eq!(SourceKind::Local.to_string(), "local");
441    }
442
443    #[test]
444    fn source_kind_display_hub() {
445        assert_eq!(SourceKind::Hub.to_string(), "hub");
446    }
447
448    #[test]
449    fn source_kind_display_file() {
450        assert_eq!(SourceKind::File.to_string(), "file");
451    }
452
453    #[test]
454    fn source_kind_from_str_local() {
455        let kind: SourceKind = "local".parse().unwrap();
456        assert_eq!(kind, SourceKind::Local);
457    }
458
459    #[test]
460    fn source_kind_from_str_hub() {
461        let kind: SourceKind = "hub".parse().unwrap();
462        assert_eq!(kind, SourceKind::Hub);
463    }
464
465    #[test]
466    fn source_kind_from_str_file() {
467        let kind: SourceKind = "file".parse().unwrap();
468        assert_eq!(kind, SourceKind::File);
469    }
470
471    #[test]
472    fn source_kind_from_str_unknown_returns_error() {
473        let result: Result<SourceKind, _> = "s3".parse();
474        assert!(result.is_err());
475        assert!(result.unwrap_err().contains("unknown source_kind"));
476    }
477
478    #[test]
479    fn source_kind_serde_json_roundtrip_local() {
480        let original = SourceKind::Local;
481        let json = serde_json::to_string(&original).unwrap();
482        assert_eq!(json, r#""local""#);
483        let back: SourceKind = serde_json::from_str(&json).unwrap();
484        assert_eq!(back, original);
485    }
486
487    #[test]
488    fn source_kind_serde_json_roundtrip_hub() {
489        let original = SourceKind::Hub;
490        let json = serde_json::to_string(&original).unwrap();
491        assert_eq!(json, r#""hub""#);
492        let back: SourceKind = serde_json::from_str(&json).unwrap();
493        assert_eq!(back, original);
494    }
495
496    #[test]
497    fn source_kind_serde_json_roundtrip_file() {
498        let original = SourceKind::File;
499        let json = serde_json::to_string(&original).unwrap();
500        assert_eq!(json, r#""file""#);
501        let back: SourceKind = serde_json::from_str(&json).unwrap();
502        assert_eq!(back, original);
503    }
504
505    #[test]
506    fn source_kind_serde_json_invalid_value_errors() {
507        let result: Result<SourceKind, _> = serde_json::from_str(r#""unknown""#);
508        assert!(result.is_err());
509    }
510
511    #[tokio::test]
512    async fn trust_row_includes_git_hash() {
513        let store = test_store().await;
514
515        store
516            .upsert_skill_trust_with_git_hash(
517                "versioned-skill",
518                "trusted",
519                SourceKind::Hub,
520                Some("https://hub.example.com/skill"),
521                None,
522                "blake3abc",
523                Some("deadbeef1234"),
524            )
525            .await
526            .unwrap();
527
528        let row = store
529            .load_skill_trust("versioned-skill")
530            .await
531            .unwrap()
532            .unwrap();
533        assert_eq!(row.git_hash.as_deref(), Some("deadbeef1234"));
534        assert_eq!(row.blake3_hash, "blake3abc");
535    }
536
537    #[tokio::test]
538    async fn upsert_without_git_hash_leaves_it_null() {
539        let store = test_store().await;
540
541        store
542            .upsert_skill_trust("git", "trusted", SourceKind::Local, None, None, "hash1")
543            .await
544            .unwrap();
545
546        let row = store.load_skill_trust("git").await.unwrap().unwrap();
547        assert!(row.git_hash.is_none());
548    }
549
550    #[tokio::test]
551    async fn upsert_each_source_kind_roundtrip() {
552        let store = test_store().await;
553        let variants = [
554            ("skill-local", SourceKind::Local),
555            ("skill-hub", SourceKind::Hub),
556            ("skill-file", SourceKind::File),
557        ];
558        for (name, kind) in &variants {
559            store
560                .upsert_skill_trust(name, "trusted", kind.clone(), None, None, "hash")
561                .await
562                .unwrap();
563            let row = store.load_skill_trust(name).await.unwrap().unwrap();
564            assert_eq!(&row.source_kind, kind);
565        }
566    }
567}