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    /// Skills shipped with the binary and provisioned at startup via `bundled.rs`.
19    Bundled,
20}
21
22impl SourceKind {
23    fn as_str(&self) -> &'static str {
24        match self {
25            Self::Local => "local",
26            Self::Hub => "hub",
27            Self::File => "file",
28            Self::Bundled => "bundled",
29        }
30    }
31}
32
33impl std::fmt::Display for SourceKind {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        f.write_str(self.as_str())
36    }
37}
38
39impl std::str::FromStr for SourceKind {
40    type Err = String;
41
42    fn from_str(s: &str) -> Result<Self, Self::Err> {
43        match s {
44            "local" => Ok(Self::Local),
45            "hub" => Ok(Self::Hub),
46            "file" => Ok(Self::File),
47            "bundled" => Ok(Self::Bundled),
48            other => Err(format!("unknown source_kind: {other}")),
49        }
50    }
51}
52
53#[derive(Debug, Clone)]
54pub struct SkillTrustRow {
55    pub skill_name: String,
56    pub trust_level: String,
57    pub source_kind: SourceKind,
58    pub source_url: Option<String>,
59    pub source_path: Option<String>,
60    pub blake3_hash: String,
61    pub updated_at: String,
62    /// Upstream git commit hash at install time (from `x-git-hash` frontmatter field).
63    pub git_hash: Option<String>,
64}
65
66type TrustTuple = (
67    String,
68    String,
69    String,
70    Option<String>,
71    Option<String>,
72    String,
73    String,
74    Option<String>,
75);
76
77fn row_from_tuple(t: TrustTuple) -> SkillTrustRow {
78    let source_kind = t.2.parse::<SourceKind>().unwrap_or(SourceKind::Local);
79    SkillTrustRow {
80        skill_name: t.0,
81        trust_level: t.1,
82        source_kind,
83        source_url: t.3,
84        source_path: t.4,
85        blake3_hash: t.5,
86        updated_at: t.6,
87        git_hash: t.7,
88    }
89}
90
91impl SqliteStore {
92    /// Upsert trust metadata for a skill.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if the database operation fails.
97    pub async fn upsert_skill_trust(
98        &self,
99        skill_name: &str,
100        trust_level: &str,
101        source_kind: SourceKind,
102        source_url: Option<&str>,
103        source_path: Option<&str>,
104        blake3_hash: &str,
105    ) -> Result<(), MemoryError> {
106        self.upsert_skill_trust_with_git_hash(
107            skill_name,
108            trust_level,
109            source_kind,
110            source_url,
111            source_path,
112            blake3_hash,
113            None,
114        )
115        .await
116    }
117
118    /// Upsert trust metadata for a skill, including an optional upstream git hash.
119    ///
120    /// `git_hash` is the upstream commit hash from the `x-git-hash` SKILL.md frontmatter field.
121    /// It tracks the upstream commit at install time and is stored separately from `blake3_hash`
122    /// (which tracks content integrity).
123    ///
124    /// # Errors
125    ///
126    /// Returns an error if the database operation fails.
127    #[allow(clippy::too_many_arguments)]
128    pub async fn upsert_skill_trust_with_git_hash(
129        &self,
130        skill_name: &str,
131        trust_level: &str,
132        source_kind: SourceKind,
133        source_url: Option<&str>,
134        source_path: Option<&str>,
135        blake3_hash: &str,
136        git_hash: Option<&str>,
137    ) -> Result<(), MemoryError> {
138        zeph_db::query(
139            sql!("INSERT INTO skill_trust \
140             (skill_name, trust_level, source_kind, source_url, source_path, blake3_hash, git_hash, updated_at) \
141             VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) \
142             ON CONFLICT(skill_name) DO UPDATE SET \
143             trust_level = excluded.trust_level, \
144             source_kind = excluded.source_kind, \
145             source_url = excluded.source_url, \
146             source_path = excluded.source_path, \
147             blake3_hash = excluded.blake3_hash, \
148             git_hash = excluded.git_hash, \
149             updated_at = CURRENT_TIMESTAMP"),
150        )
151        .bind(skill_name)
152        .bind(trust_level)
153        .bind(source_kind.as_str())
154        .bind(source_url)
155        .bind(source_path)
156        .bind(blake3_hash)
157        .bind(git_hash)
158        .execute(&self.pool)
159        .await?;
160        Ok(())
161    }
162
163    /// Load trust metadata for a single skill.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if the query fails.
168    pub async fn load_skill_trust(
169        &self,
170        skill_name: &str,
171    ) -> Result<Option<SkillTrustRow>, MemoryError> {
172        let row: Option<TrustTuple> = zeph_db::query_as(sql!(
173            "SELECT skill_name, trust_level, source_kind, source_url, source_path, \
174             blake3_hash, updated_at, git_hash \
175             FROM skill_trust WHERE skill_name = ?"
176        ))
177        .bind(skill_name)
178        .fetch_optional(&self.pool)
179        .await?;
180        Ok(row.map(row_from_tuple))
181    }
182
183    /// Load all skill trust entries.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if the query fails.
188    pub async fn load_all_skill_trust(&self) -> Result<Vec<SkillTrustRow>, MemoryError> {
189        let rows: Vec<TrustTuple> = zeph_db::query_as(sql!(
190            "SELECT skill_name, trust_level, source_kind, source_url, source_path, \
191             blake3_hash, updated_at, git_hash \
192             FROM skill_trust ORDER BY skill_name"
193        ))
194        .fetch_all(&self.pool)
195        .await?;
196        Ok(rows.into_iter().map(row_from_tuple).collect())
197    }
198
199    /// Update only the trust level for a skill.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if the skill does not exist or the update fails.
204    pub async fn set_skill_trust_level(
205        &self,
206        skill_name: &str,
207        trust_level: &str,
208    ) -> Result<bool, MemoryError> {
209        let result = zeph_db::query(
210            sql!("UPDATE skill_trust SET trust_level = ?, updated_at = CURRENT_TIMESTAMP WHERE skill_name = ?"),
211        )
212        .bind(trust_level)
213        .bind(skill_name)
214        .execute(&self.pool)
215        .await?;
216        Ok(result.rows_affected() > 0)
217    }
218
219    /// Delete trust entry for a skill.
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if the delete fails.
224    pub async fn delete_skill_trust(&self, skill_name: &str) -> Result<bool, MemoryError> {
225        let result = zeph_db::query(sql!("DELETE FROM skill_trust WHERE skill_name = ?"))
226            .bind(skill_name)
227            .execute(&self.pool)
228            .await?;
229        Ok(result.rows_affected() > 0)
230    }
231
232    /// Update the blake3 hash for a skill.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if the update fails.
237    pub async fn update_skill_hash(
238        &self,
239        skill_name: &str,
240        blake3_hash: &str,
241    ) -> Result<bool, MemoryError> {
242        let result = zeph_db::query(
243            sql!("UPDATE skill_trust SET blake3_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE skill_name = ?"),
244        )
245        .bind(blake3_hash)
246        .bind(skill_name)
247        .execute(&self.pool)
248        .await?;
249        Ok(result.rows_affected() > 0)
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    async fn test_store() -> SqliteStore {
258        SqliteStore::new(":memory:").await.unwrap()
259    }
260
261    #[tokio::test]
262    async fn upsert_and_load() {
263        let store = test_store().await;
264
265        store
266            .upsert_skill_trust("git", "trusted", SourceKind::Local, None, None, "abc123")
267            .await
268            .unwrap();
269
270        let row = store.load_skill_trust("git").await.unwrap().unwrap();
271        assert_eq!(row.skill_name, "git");
272        assert_eq!(row.trust_level, "trusted");
273        assert_eq!(row.source_kind, SourceKind::Local);
274        assert_eq!(row.blake3_hash, "abc123");
275    }
276
277    #[tokio::test]
278    async fn upsert_updates_existing() {
279        let store = test_store().await;
280
281        store
282            .upsert_skill_trust("git", "quarantined", SourceKind::Local, None, None, "hash1")
283            .await
284            .unwrap();
285        store
286            .upsert_skill_trust("git", "trusted", SourceKind::Local, None, None, "hash2")
287            .await
288            .unwrap();
289
290        let row = store.load_skill_trust("git").await.unwrap().unwrap();
291        assert_eq!(row.trust_level, "trusted");
292        assert_eq!(row.blake3_hash, "hash2");
293    }
294
295    #[tokio::test]
296    async fn load_nonexistent() {
297        let store = test_store().await;
298        let row = store.load_skill_trust("nope").await.unwrap();
299        assert!(row.is_none());
300    }
301
302    #[tokio::test]
303    async fn load_all() {
304        let store = test_store().await;
305
306        store
307            .upsert_skill_trust("alpha", "trusted", SourceKind::Local, None, None, "h1")
308            .await
309            .unwrap();
310        store
311            .upsert_skill_trust(
312                "beta",
313                "quarantined",
314                SourceKind::Hub,
315                Some("https://hub.example.com"),
316                None,
317                "h2",
318            )
319            .await
320            .unwrap();
321
322        let rows = store.load_all_skill_trust().await.unwrap();
323        assert_eq!(rows.len(), 2);
324        assert_eq!(rows[0].skill_name, "alpha");
325        assert_eq!(rows[1].skill_name, "beta");
326    }
327
328    #[tokio::test]
329    async fn set_trust_level() {
330        let store = test_store().await;
331
332        store
333            .upsert_skill_trust("git", "quarantined", SourceKind::Local, None, None, "h1")
334            .await
335            .unwrap();
336
337        let updated = store.set_skill_trust_level("git", "blocked").await.unwrap();
338        assert!(updated);
339
340        let row = store.load_skill_trust("git").await.unwrap().unwrap();
341        assert_eq!(row.trust_level, "blocked");
342    }
343
344    #[tokio::test]
345    async fn set_trust_level_nonexistent() {
346        let store = test_store().await;
347        let updated = store
348            .set_skill_trust_level("nope", "blocked")
349            .await
350            .unwrap();
351        assert!(!updated);
352    }
353
354    #[tokio::test]
355    async fn delete_trust() {
356        let store = test_store().await;
357
358        store
359            .upsert_skill_trust("git", "trusted", SourceKind::Local, None, None, "h1")
360            .await
361            .unwrap();
362
363        let deleted = store.delete_skill_trust("git").await.unwrap();
364        assert!(deleted);
365
366        let row = store.load_skill_trust("git").await.unwrap();
367        assert!(row.is_none());
368    }
369
370    #[tokio::test]
371    async fn delete_nonexistent() {
372        let store = test_store().await;
373        let deleted = store.delete_skill_trust("nope").await.unwrap();
374        assert!(!deleted);
375    }
376
377    #[tokio::test]
378    async fn update_hash() {
379        let store = test_store().await;
380
381        store
382            .upsert_skill_trust("git", "verified", SourceKind::Local, None, None, "old_hash")
383            .await
384            .unwrap();
385
386        let updated = store.update_skill_hash("git", "new_hash").await.unwrap();
387        assert!(updated);
388
389        let row = store.load_skill_trust("git").await.unwrap().unwrap();
390        assert_eq!(row.blake3_hash, "new_hash");
391    }
392
393    #[tokio::test]
394    async fn source_with_url() {
395        let store = test_store().await;
396
397        store
398            .upsert_skill_trust(
399                "remote-skill",
400                "quarantined",
401                SourceKind::Hub,
402                Some("https://hub.example.com/skill"),
403                None,
404                "h1",
405            )
406            .await
407            .unwrap();
408
409        let row = store
410            .load_skill_trust("remote-skill")
411            .await
412            .unwrap()
413            .unwrap();
414        assert_eq!(row.source_kind, SourceKind::Hub);
415        assert_eq!(
416            row.source_url.as_deref(),
417            Some("https://hub.example.com/skill")
418        );
419    }
420
421    #[tokio::test]
422    async fn source_with_path() {
423        let store = test_store().await;
424
425        store
426            .upsert_skill_trust(
427                "file-skill",
428                "quarantined",
429                SourceKind::File,
430                None,
431                Some("/tmp/skill.tar.gz"),
432                "h1",
433            )
434            .await
435            .unwrap();
436
437        let row = store.load_skill_trust("file-skill").await.unwrap().unwrap();
438        assert_eq!(row.source_kind, SourceKind::File);
439        assert_eq!(row.source_path.as_deref(), Some("/tmp/skill.tar.gz"));
440    }
441
442    #[test]
443    fn source_kind_display_local() {
444        assert_eq!(SourceKind::Local.to_string(), "local");
445    }
446
447    #[test]
448    fn source_kind_display_hub() {
449        assert_eq!(SourceKind::Hub.to_string(), "hub");
450    }
451
452    #[test]
453    fn source_kind_display_file() {
454        assert_eq!(SourceKind::File.to_string(), "file");
455    }
456
457    #[test]
458    fn source_kind_from_str_local() {
459        let kind: SourceKind = "local".parse().unwrap();
460        assert_eq!(kind, SourceKind::Local);
461    }
462
463    #[test]
464    fn source_kind_from_str_hub() {
465        let kind: SourceKind = "hub".parse().unwrap();
466        assert_eq!(kind, SourceKind::Hub);
467    }
468
469    #[test]
470    fn source_kind_from_str_file() {
471        let kind: SourceKind = "file".parse().unwrap();
472        assert_eq!(kind, SourceKind::File);
473    }
474
475    #[test]
476    fn source_kind_from_str_unknown_returns_error() {
477        let result: Result<SourceKind, _> = "s3".parse();
478        assert!(result.is_err());
479        assert!(result.unwrap_err().contains("unknown source_kind"));
480    }
481
482    #[test]
483    fn source_kind_serde_json_roundtrip_local() {
484        let original = SourceKind::Local;
485        let json = serde_json::to_string(&original).unwrap();
486        assert_eq!(json, r#""local""#);
487        let back: SourceKind = serde_json::from_str(&json).unwrap();
488        assert_eq!(back, original);
489    }
490
491    #[test]
492    fn source_kind_serde_json_roundtrip_hub() {
493        let original = SourceKind::Hub;
494        let json = serde_json::to_string(&original).unwrap();
495        assert_eq!(json, r#""hub""#);
496        let back: SourceKind = serde_json::from_str(&json).unwrap();
497        assert_eq!(back, original);
498    }
499
500    #[test]
501    fn source_kind_serde_json_roundtrip_file() {
502        let original = SourceKind::File;
503        let json = serde_json::to_string(&original).unwrap();
504        assert_eq!(json, r#""file""#);
505        let back: SourceKind = serde_json::from_str(&json).unwrap();
506        assert_eq!(back, original);
507    }
508
509    #[test]
510    fn source_kind_serde_json_invalid_value_errors() {
511        let result: Result<SourceKind, _> = serde_json::from_str(r#""unknown""#);
512        assert!(result.is_err());
513    }
514
515    #[tokio::test]
516    async fn trust_row_includes_git_hash() {
517        let store = test_store().await;
518
519        store
520            .upsert_skill_trust_with_git_hash(
521                "versioned-skill",
522                "trusted",
523                SourceKind::Hub,
524                Some("https://hub.example.com/skill"),
525                None,
526                "blake3abc",
527                Some("deadbeef1234"),
528            )
529            .await
530            .unwrap();
531
532        let row = store
533            .load_skill_trust("versioned-skill")
534            .await
535            .unwrap()
536            .unwrap();
537        assert_eq!(row.git_hash.as_deref(), Some("deadbeef1234"));
538        assert_eq!(row.blake3_hash, "blake3abc");
539    }
540
541    #[tokio::test]
542    async fn upsert_without_git_hash_leaves_it_null() {
543        let store = test_store().await;
544
545        store
546            .upsert_skill_trust("git", "trusted", SourceKind::Local, None, None, "hash1")
547            .await
548            .unwrap();
549
550        let row = store.load_skill_trust("git").await.unwrap().unwrap();
551        assert!(row.git_hash.is_none());
552    }
553
554    #[tokio::test]
555    async fn upsert_each_source_kind_roundtrip() {
556        let store = test_store().await;
557        let variants = [
558            ("skill-local", SourceKind::Local),
559            ("skill-hub", SourceKind::Hub),
560            ("skill-file", SourceKind::File),
561            ("skill-bundled", SourceKind::Bundled),
562        ];
563        for (name, kind) in &variants {
564            store
565                .upsert_skill_trust(name, "trusted", kind.clone(), None, None, "hash")
566                .await
567                .unwrap();
568            let row = store.load_skill_trust(name).await.unwrap().unwrap();
569            assert_eq!(&row.source_kind, kind);
570        }
571    }
572
573    #[test]
574    fn source_kind_display_bundled() {
575        assert_eq!(SourceKind::Bundled.to_string(), "bundled");
576    }
577
578    #[test]
579    fn source_kind_from_str_bundled() {
580        let kind: SourceKind = "bundled".parse().unwrap();
581        assert_eq!(kind, SourceKind::Bundled);
582    }
583
584    #[test]
585    fn source_kind_serde_json_roundtrip_bundled() {
586        let original = SourceKind::Bundled;
587        let json = serde_json::to_string(&original).unwrap();
588        assert_eq!(json, r#""bundled""#);
589        let back: SourceKind = serde_json::from_str(&json).unwrap();
590        assert_eq!(back, original);
591    }
592
593    #[test]
594    fn source_kind_from_str_unknown_falls_back_to_local_in_row_from_tuple() {
595        // Verify that unknown DB values (e.g., from a future version downgrade)
596        // deserialize gracefully via the unwrap_or(Local) in row_from_tuple.
597        let result: Result<SourceKind, _> = "future_variant".parse();
598        assert!(result.is_err());
599        // row_from_tuple uses unwrap_or(SourceKind::Local) — simulate that here.
600        let fallback = result.unwrap_or(SourceKind::Local);
601        assert_eq!(fallback, SourceKind::Local);
602    }
603
604    // Scenario 2: Bundled trust level is preserved when re-upserted with the same source_kind.
605    // This covers the hot-reload path where hash matches and source_kind is unchanged.
606    #[tokio::test]
607    async fn bundled_trust_preserved_on_same_source_kind_upsert() {
608        let store = test_store().await;
609
610        store
611            .upsert_skill_trust(
612                "web-search",
613                "trusted",
614                SourceKind::Bundled,
615                None,
616                None,
617                "hash1",
618            )
619            .await
620            .unwrap();
621
622        // Simulate a second startup (hash unchanged, source_kind unchanged) — trust must be preserved.
623        store
624            .upsert_skill_trust(
625                "web-search",
626                "trusted",
627                SourceKind::Bundled,
628                None,
629                None,
630                "hash1",
631            )
632            .await
633            .unwrap();
634
635        let row = store.load_skill_trust("web-search").await.unwrap().unwrap();
636        assert_eq!(row.source_kind, SourceKind::Bundled);
637        assert_eq!(row.trust_level, "trusted");
638    }
639
640    // Scenario 3: Migration from hub/quarantined to bundled/trusted when .bundled marker appears.
641    // The store upsert always overwrites source_kind and trust_level when called with new values.
642    #[tokio::test]
643    async fn migration_hub_quarantined_to_bundled_trusted() {
644        let store = test_store().await;
645
646        // Initial state: existing install has hub/quarantined.
647        store
648            .upsert_skill_trust("git", "quarantined", SourceKind::Hub, None, None, "hash1")
649            .await
650            .unwrap();
651
652        let row = store.load_skill_trust("git").await.unwrap().unwrap();
653        assert_eq!(row.source_kind, SourceKind::Hub);
654        assert_eq!(row.trust_level, "quarantined");
655
656        // After runner detects .bundled marker: upsert with Bundled/trusted (initial_level from bundled_level).
657        store
658            .upsert_skill_trust("git", "trusted", SourceKind::Bundled, None, None, "hash1")
659            .await
660            .unwrap();
661
662        let row = store.load_skill_trust("git").await.unwrap().unwrap();
663        assert_eq!(row.source_kind, SourceKind::Bundled);
664        assert_eq!(row.trust_level, "trusted");
665    }
666
667    // Regression test for C1: operator-blocked bundled skills must not be unblocked by migration.
668    // The store layer always overwrites; caller logic (runner/mod) must pass "blocked" through.
669    #[tokio::test]
670    async fn operator_blocked_bundled_skill_stays_blocked_when_upserted_with_blocked() {
671        let store = test_store().await;
672
673        // Existing install: hub/blocked (operator explicitly blocked this skill).
674        store
675            .upsert_skill_trust(
676                "web-search",
677                "blocked",
678                SourceKind::Hub,
679                None,
680                None,
681                "hash1",
682            )
683            .await
684            .unwrap();
685
686        // Migration: runner detects .bundled marker but preserves "blocked" (caller responsibility).
687        store
688            .upsert_skill_trust(
689                "web-search",
690                "blocked",
691                SourceKind::Bundled,
692                None,
693                None,
694                "hash1",
695            )
696            .await
697            .unwrap();
698
699        let row = store.load_skill_trust("web-search").await.unwrap().unwrap();
700        assert_eq!(row.source_kind, SourceKind::Bundled);
701        assert_eq!(
702            row.trust_level, "blocked",
703            "operator block must survive source_kind migration"
704        );
705    }
706
707    // Scenario 4: Configurable bundled_level (e.g., "supervised") is applied during classification.
708    // This tests that the store persists any trust level string correctly for non-default configs.
709    #[tokio::test]
710    async fn bundled_skill_with_configured_supervised_level() {
711        let store = test_store().await;
712
713        store
714            .upsert_skill_trust(
715                "git",
716                "supervised",
717                SourceKind::Bundled,
718                None,
719                None,
720                "hash1",
721            )
722            .await
723            .unwrap();
724
725        let row = store.load_skill_trust("git").await.unwrap().unwrap();
726        assert_eq!(row.source_kind, SourceKind::Bundled);
727        assert_eq!(row.trust_level, "supervised");
728    }
729}