Skip to main content

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