1use serde::{Deserialize, Serialize};
5use zeph_common::SkillTrustLevel;
6#[allow(unused_imports)]
7use zeph_db::sql;
8
9use super::SqliteStore;
10use crate::error::MemoryError;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15#[non_exhaustive]
16pub enum SourceKind {
17 Local,
18 Hub,
19 File,
20 Bundled,
22}
23
24impl SourceKind {
25 fn as_str(&self) -> &'static str {
26 match self {
27 Self::Local => "local",
28 Self::Hub => "hub",
29 Self::File => "file",
30 Self::Bundled => "bundled",
31 }
32 }
33}
34
35impl std::fmt::Display for SourceKind {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 f.write_str(self.as_str())
38 }
39}
40
41impl std::str::FromStr for SourceKind {
42 type Err = String;
43
44 fn from_str(s: &str) -> Result<Self, Self::Err> {
45 match s {
46 "local" => Ok(Self::Local),
47 "hub" => Ok(Self::Hub),
48 "file" => Ok(Self::File),
49 "bundled" => Ok(Self::Bundled),
50 other => Err(format!("unknown source_kind: {other}")),
51 }
52 }
53}
54
55#[derive(Debug, Clone)]
56pub struct SkillTrustRow {
57 pub skill_name: String,
58 pub trust_level: SkillTrustLevel,
59 pub source_kind: SourceKind,
60 pub source_url: Option<String>,
61 pub source_path: Option<String>,
62 pub blake3_hash: String,
63 pub updated_at: String,
64 pub git_hash: Option<String>,
66 pub requires_trust_check: bool,
70}
71
72type TrustTuple = (
74 String,
75 String,
76 String,
77 Option<String>,
78 Option<String>,
79 String,
80 String,
81 Option<String>,
82 i32,
83);
84
85fn row_from_tuple(t: TrustTuple) -> SkillTrustRow {
86 let source_kind = t.2.parse::<SourceKind>().unwrap_or(SourceKind::Local);
87 let trust_level = t.1.parse::<SkillTrustLevel>().unwrap_or_default();
88 SkillTrustRow {
89 skill_name: t.0,
90 trust_level,
91 source_kind,
92 source_url: t.3,
93 source_path: t.4,
94 blake3_hash: t.5,
95 updated_at: t.6,
96 git_hash: t.7,
97 requires_trust_check: t.8 != 0,
98 }
99}
100
101impl SqliteStore {
102 #[tracing::instrument(name = "memory.trust.upsert", skip_all, fields(skill = %skill_name))]
108 pub async fn upsert_skill_trust(
109 &self,
110 skill_name: &str,
111 trust_level: SkillTrustLevel,
112 source_kind: SourceKind,
113 source_url: Option<&str>,
114 source_path: Option<&str>,
115 blake3_hash: &str,
116 ) -> Result<(), MemoryError> {
117 self.upsert_skill_trust_with_git_hash(
118 skill_name,
119 trust_level,
120 source_kind,
121 source_url,
122 source_path,
123 blake3_hash,
124 None,
125 )
126 .await
127 }
128
129 #[allow(clippy::too_many_arguments)]
140 #[tracing::instrument(name = "memory.trust.upsert_with_git_hash", skip_all, fields(skill = %skill_name))]
141 pub async fn upsert_skill_trust_with_git_hash(
142 &self,
143 skill_name: &str,
144 trust_level: SkillTrustLevel,
145 source_kind: SourceKind,
146 source_url: Option<&str>,
147 source_path: Option<&str>,
148 blake3_hash: &str,
149 git_hash: Option<&str>,
150 ) -> Result<(), MemoryError> {
151 zeph_db::query(
152 sql!("INSERT INTO skill_trust \
153 (skill_name, trust_level, source_kind, source_url, source_path, blake3_hash, git_hash, updated_at) \
154 VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) \
155 ON CONFLICT(skill_name) DO UPDATE SET \
156 trust_level = excluded.trust_level, \
157 source_kind = excluded.source_kind, \
158 source_url = excluded.source_url, \
159 source_path = excluded.source_path, \
160 blake3_hash = excluded.blake3_hash, \
161 git_hash = excluded.git_hash, \
162 updated_at = CURRENT_TIMESTAMP"),
163 )
164 .bind(skill_name)
165 .bind(trust_level.as_str())
166 .bind(source_kind.as_str())
167 .bind(source_url)
168 .bind(source_path)
169 .bind(blake3_hash)
170 .bind(git_hash)
171 .execute(&self.pool)
172 .await?;
173 Ok(())
174 }
175
176 #[tracing::instrument(name = "memory.trust.load", skip_all, fields(skill = %skill_name))]
182 pub async fn load_skill_trust(
183 &self,
184 skill_name: &str,
185 ) -> Result<Option<SkillTrustRow>, MemoryError> {
186 let updated_at_sel =
189 <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("updated_at");
190 let raw = format!(
191 "SELECT skill_name, trust_level, source_kind, source_url, source_path, \
192 blake3_hash, {updated_at_sel}, git_hash, requires_trust_check \
193 FROM skill_trust WHERE skill_name = ?"
194 );
195 let query_sql = zeph_db::rewrite_placeholders(&raw);
196 let row: Option<TrustTuple> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
197 .bind(skill_name)
198 .fetch_optional(&self.pool)
199 .await?;
200 Ok(row.map(row_from_tuple))
201 }
202
203 #[tracing::instrument(name = "memory.trust.load_all", skip_all)]
209 pub async fn load_all_skill_trust(&self) -> Result<Vec<SkillTrustRow>, MemoryError> {
210 let updated_at_sel =
212 <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("updated_at");
213 let raw = format!(
214 "SELECT skill_name, trust_level, source_kind, source_url, source_path, \
215 blake3_hash, {updated_at_sel}, git_hash, requires_trust_check \
216 FROM skill_trust ORDER BY skill_name"
217 );
218 let query_sql = zeph_db::rewrite_placeholders(&raw);
219 let rows: Vec<TrustTuple> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
220 .fetch_all(&self.pool)
221 .await?;
222 Ok(rows.into_iter().map(row_from_tuple).collect())
223 }
224
225 #[tracing::instrument(name = "memory.trust.set_level", skip_all, fields(skill = %skill_name))]
231 pub async fn set_skill_trust_level(
232 &self,
233 skill_name: &str,
234 trust_level: SkillTrustLevel,
235 ) -> Result<bool, MemoryError> {
236 let result = zeph_db::query(
237 sql!("UPDATE skill_trust SET trust_level = ?, updated_at = CURRENT_TIMESTAMP WHERE skill_name = ?"),
238 )
239 .bind(trust_level.as_str())
240 .bind(skill_name)
241 .execute(&self.pool)
242 .await?;
243 Ok(result.rows_affected() > 0)
244 }
245
246 #[tracing::instrument(name = "memory.trust.delete", skip_all, fields(skill = %skill_name))]
252 pub async fn delete_skill_trust(&self, skill_name: &str) -> Result<bool, MemoryError> {
253 let result = zeph_db::query(sql!("DELETE FROM skill_trust WHERE skill_name = ?"))
254 .bind(skill_name)
255 .execute(&self.pool)
256 .await?;
257 Ok(result.rows_affected() > 0)
258 }
259
260 #[tracing::instrument(name = "memory.trust.set_check_flag", skip_all, fields(skill = %skill_name))]
269 pub async fn set_requires_trust_check(
270 &self,
271 skill_name: &str,
272 enabled: bool,
273 ) -> Result<bool, MemoryError> {
274 let flag = i64::from(enabled);
275 let result = zeph_db::query(
276 sql!("UPDATE skill_trust SET requires_trust_check = ?, updated_at = CURRENT_TIMESTAMP WHERE skill_name = ?"),
277 )
278 .bind(flag)
279 .bind(skill_name)
280 .execute(&self.pool)
281 .await?;
282 Ok(result.rows_affected() > 0)
283 }
284
285 #[tracing::instrument(name = "memory.trust.update_hash", skip_all, fields(skill = %skill_name))]
291 pub async fn update_skill_hash(
292 &self,
293 skill_name: &str,
294 blake3_hash: &str,
295 ) -> Result<bool, MemoryError> {
296 let result = zeph_db::query(
297 sql!("UPDATE skill_trust SET blake3_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE skill_name = ?"),
298 )
299 .bind(blake3_hash)
300 .bind(skill_name)
301 .execute(&self.pool)
302 .await?;
303 Ok(result.rows_affected() > 0)
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 async fn test_store() -> SqliteStore {
312 SqliteStore::new(":memory:").await.unwrap()
313 }
314
315 #[tokio::test]
316 async fn upsert_and_load() {
317 let store = test_store().await;
318
319 store
320 .upsert_skill_trust(
321 "git",
322 SkillTrustLevel::Trusted,
323 SourceKind::Local,
324 None,
325 None,
326 "abc123",
327 )
328 .await
329 .unwrap();
330
331 let row = store.load_skill_trust("git").await.unwrap().unwrap();
332 assert_eq!(row.skill_name, "git");
333 assert_eq!(row.trust_level, SkillTrustLevel::Trusted);
334 assert_eq!(row.source_kind, SourceKind::Local);
335 assert_eq!(row.blake3_hash, "abc123");
336 }
337
338 #[tokio::test]
339 async fn upsert_updates_existing() {
340 let store = test_store().await;
341
342 store
343 .upsert_skill_trust(
344 "git",
345 SkillTrustLevel::Quarantined,
346 SourceKind::Local,
347 None,
348 None,
349 "hash1",
350 )
351 .await
352 .unwrap();
353 store
354 .upsert_skill_trust(
355 "git",
356 SkillTrustLevel::Trusted,
357 SourceKind::Local,
358 None,
359 None,
360 "hash2",
361 )
362 .await
363 .unwrap();
364
365 let row = store.load_skill_trust("git").await.unwrap().unwrap();
366 assert_eq!(row.trust_level, SkillTrustLevel::Trusted);
367 assert_eq!(row.blake3_hash, "hash2");
368 }
369
370 #[tokio::test]
371 async fn load_nonexistent() {
372 let store = test_store().await;
373 let row = store.load_skill_trust("nope").await.unwrap();
374 assert!(row.is_none());
375 }
376
377 #[tokio::test]
378 async fn load_all() {
379 let store = test_store().await;
380
381 store
382 .upsert_skill_trust(
383 "alpha",
384 SkillTrustLevel::Trusted,
385 SourceKind::Local,
386 None,
387 None,
388 "h1",
389 )
390 .await
391 .unwrap();
392 store
393 .upsert_skill_trust(
394 "beta",
395 SkillTrustLevel::Quarantined,
396 SourceKind::Hub,
397 Some("https://hub.example.com"),
398 None,
399 "h2",
400 )
401 .await
402 .unwrap();
403
404 let rows = store.load_all_skill_trust().await.unwrap();
405 assert_eq!(rows.len(), 2);
406 assert_eq!(rows[0].skill_name, "alpha");
407 assert_eq!(rows[1].skill_name, "beta");
408 }
409
410 #[tokio::test]
411 async fn set_trust_level() {
412 let store = test_store().await;
413
414 store
415 .upsert_skill_trust(
416 "git",
417 SkillTrustLevel::Quarantined,
418 SourceKind::Local,
419 None,
420 None,
421 "h1",
422 )
423 .await
424 .unwrap();
425
426 let updated = store
427 .set_skill_trust_level("git", SkillTrustLevel::Blocked)
428 .await
429 .unwrap();
430 assert!(updated);
431
432 let row = store.load_skill_trust("git").await.unwrap().unwrap();
433 assert_eq!(row.trust_level, SkillTrustLevel::Blocked);
434 }
435
436 #[tokio::test]
437 async fn set_trust_level_nonexistent() {
438 let store = test_store().await;
439 let updated = store
440 .set_skill_trust_level("nope", SkillTrustLevel::Blocked)
441 .await
442 .unwrap();
443 assert!(!updated);
444 }
445
446 #[tokio::test]
447 async fn delete_trust() {
448 let store = test_store().await;
449
450 store
451 .upsert_skill_trust(
452 "git",
453 SkillTrustLevel::Trusted,
454 SourceKind::Local,
455 None,
456 None,
457 "h1",
458 )
459 .await
460 .unwrap();
461
462 let deleted = store.delete_skill_trust("git").await.unwrap();
463 assert!(deleted);
464
465 let row = store.load_skill_trust("git").await.unwrap();
466 assert!(row.is_none());
467 }
468
469 #[tokio::test]
470 async fn delete_nonexistent() {
471 let store = test_store().await;
472 let deleted = store.delete_skill_trust("nope").await.unwrap();
473 assert!(!deleted);
474 }
475
476 #[tokio::test]
477 async fn update_hash() {
478 let store = test_store().await;
479
480 store
481 .upsert_skill_trust(
482 "git",
483 SkillTrustLevel::Verified,
484 SourceKind::Local,
485 None,
486 None,
487 "old_hash",
488 )
489 .await
490 .unwrap();
491
492 let updated = store.update_skill_hash("git", "new_hash").await.unwrap();
493 assert!(updated);
494
495 let row = store.load_skill_trust("git").await.unwrap().unwrap();
496 assert_eq!(row.blake3_hash, "new_hash");
497 }
498
499 #[tokio::test]
500 async fn source_with_url() {
501 let store = test_store().await;
502
503 store
504 .upsert_skill_trust(
505 "remote-skill",
506 SkillTrustLevel::Quarantined,
507 SourceKind::Hub,
508 Some("https://hub.example.com/skill"),
509 None,
510 "h1",
511 )
512 .await
513 .unwrap();
514
515 let row = store
516 .load_skill_trust("remote-skill")
517 .await
518 .unwrap()
519 .unwrap();
520 assert_eq!(row.source_kind, SourceKind::Hub);
521 assert_eq!(
522 row.source_url.as_deref(),
523 Some("https://hub.example.com/skill")
524 );
525 }
526
527 #[tokio::test]
528 async fn source_with_path() {
529 let store = test_store().await;
530
531 store
532 .upsert_skill_trust(
533 "file-skill",
534 SkillTrustLevel::Quarantined,
535 SourceKind::File,
536 None,
537 Some("/tmp/skill.tar.gz"),
538 "h1",
539 )
540 .await
541 .unwrap();
542
543 let row = store.load_skill_trust("file-skill").await.unwrap().unwrap();
544 assert_eq!(row.source_kind, SourceKind::File);
545 assert_eq!(row.source_path.as_deref(), Some("/tmp/skill.tar.gz"));
546 }
547
548 #[test]
549 fn source_kind_display_local() {
550 assert_eq!(SourceKind::Local.to_string(), "local");
551 }
552
553 #[test]
554 fn source_kind_display_hub() {
555 assert_eq!(SourceKind::Hub.to_string(), "hub");
556 }
557
558 #[test]
559 fn source_kind_display_file() {
560 assert_eq!(SourceKind::File.to_string(), "file");
561 }
562
563 #[test]
564 fn source_kind_from_str_local() {
565 let kind: SourceKind = "local".parse().unwrap();
566 assert_eq!(kind, SourceKind::Local);
567 }
568
569 #[test]
570 fn source_kind_from_str_hub() {
571 let kind: SourceKind = "hub".parse().unwrap();
572 assert_eq!(kind, SourceKind::Hub);
573 }
574
575 #[test]
576 fn source_kind_from_str_file() {
577 let kind: SourceKind = "file".parse().unwrap();
578 assert_eq!(kind, SourceKind::File);
579 }
580
581 #[test]
582 fn source_kind_from_str_unknown_returns_error() {
583 let result: Result<SourceKind, _> = "s3".parse();
584 assert!(result.is_err());
585 assert!(result.unwrap_err().contains("unknown source_kind"));
586 }
587
588 #[test]
589 fn source_kind_serde_json_roundtrip_local() {
590 let original = SourceKind::Local;
591 let json = serde_json::to_string(&original).unwrap();
592 assert_eq!(json, r#""local""#);
593 let back: SourceKind = serde_json::from_str(&json).unwrap();
594 assert_eq!(back, original);
595 }
596
597 #[test]
598 fn source_kind_serde_json_roundtrip_hub() {
599 let original = SourceKind::Hub;
600 let json = serde_json::to_string(&original).unwrap();
601 assert_eq!(json, r#""hub""#);
602 let back: SourceKind = serde_json::from_str(&json).unwrap();
603 assert_eq!(back, original);
604 }
605
606 #[test]
607 fn source_kind_serde_json_roundtrip_file() {
608 let original = SourceKind::File;
609 let json = serde_json::to_string(&original).unwrap();
610 assert_eq!(json, r#""file""#);
611 let back: SourceKind = serde_json::from_str(&json).unwrap();
612 assert_eq!(back, original);
613 }
614
615 #[test]
616 fn source_kind_serde_json_invalid_value_errors() {
617 let result: Result<SourceKind, _> = serde_json::from_str(r#""unknown""#);
618 assert!(result.is_err());
619 }
620
621 #[tokio::test]
622 async fn trust_row_includes_git_hash() {
623 let store = test_store().await;
624
625 store
626 .upsert_skill_trust_with_git_hash(
627 "versioned-skill",
628 SkillTrustLevel::Trusted,
629 SourceKind::Hub,
630 Some("https://hub.example.com/skill"),
631 None,
632 "blake3abc",
633 Some("deadbeef1234"),
634 )
635 .await
636 .unwrap();
637
638 let row = store
639 .load_skill_trust("versioned-skill")
640 .await
641 .unwrap()
642 .unwrap();
643 assert_eq!(row.git_hash.as_deref(), Some("deadbeef1234"));
644 assert_eq!(row.blake3_hash, "blake3abc");
645 }
646
647 #[tokio::test]
648 async fn upsert_without_git_hash_leaves_it_null() {
649 let store = test_store().await;
650
651 store
652 .upsert_skill_trust(
653 "git",
654 SkillTrustLevel::Trusted,
655 SourceKind::Local,
656 None,
657 None,
658 "hash1",
659 )
660 .await
661 .unwrap();
662
663 let row = store.load_skill_trust("git").await.unwrap().unwrap();
664 assert!(row.git_hash.is_none());
665 }
666
667 #[tokio::test]
668 async fn upsert_each_source_kind_roundtrip() {
669 let store = test_store().await;
670 let variants = [
671 ("skill-local", SourceKind::Local),
672 ("skill-hub", SourceKind::Hub),
673 ("skill-file", SourceKind::File),
674 ("skill-bundled", SourceKind::Bundled),
675 ];
676 for (name, kind) in &variants {
677 store
678 .upsert_skill_trust(
679 name,
680 SkillTrustLevel::Trusted,
681 kind.clone(),
682 None,
683 None,
684 "hash",
685 )
686 .await
687 .unwrap();
688 let row = store.load_skill_trust(name).await.unwrap().unwrap();
689 assert_eq!(&row.source_kind, kind);
690 }
691 }
692
693 #[test]
694 fn source_kind_display_bundled() {
695 assert_eq!(SourceKind::Bundled.to_string(), "bundled");
696 }
697
698 #[test]
699 fn source_kind_from_str_bundled() {
700 let kind: SourceKind = "bundled".parse().unwrap();
701 assert_eq!(kind, SourceKind::Bundled);
702 }
703
704 #[test]
705 fn source_kind_serde_json_roundtrip_bundled() {
706 let original = SourceKind::Bundled;
707 let json = serde_json::to_string(&original).unwrap();
708 assert_eq!(json, r#""bundled""#);
709 let back: SourceKind = serde_json::from_str(&json).unwrap();
710 assert_eq!(back, original);
711 }
712
713 #[test]
714 fn source_kind_from_str_unknown_falls_back_to_local_in_row_from_tuple() {
715 let result: Result<SourceKind, _> = "future_variant".parse();
718 assert!(result.is_err());
719 let fallback = result.unwrap_or(SourceKind::Local);
721 assert_eq!(fallback, SourceKind::Local);
722 }
723
724 #[tokio::test]
727 async fn bundled_trust_preserved_on_same_source_kind_upsert() {
728 let store = test_store().await;
729
730 store
731 .upsert_skill_trust(
732 "web-search",
733 SkillTrustLevel::Trusted,
734 SourceKind::Bundled,
735 None,
736 None,
737 "hash1",
738 )
739 .await
740 .unwrap();
741
742 store
744 .upsert_skill_trust(
745 "web-search",
746 SkillTrustLevel::Trusted,
747 SourceKind::Bundled,
748 None,
749 None,
750 "hash1",
751 )
752 .await
753 .unwrap();
754
755 let row = store.load_skill_trust("web-search").await.unwrap().unwrap();
756 assert_eq!(row.source_kind, SourceKind::Bundled);
757 assert_eq!(row.trust_level, SkillTrustLevel::Trusted);
758 }
759
760 #[tokio::test]
763 async fn migration_hub_quarantined_to_bundled_trusted() {
764 let store = test_store().await;
765
766 store
768 .upsert_skill_trust(
769 "git",
770 SkillTrustLevel::Quarantined,
771 SourceKind::Hub,
772 None,
773 None,
774 "hash1",
775 )
776 .await
777 .unwrap();
778
779 let row = store.load_skill_trust("git").await.unwrap().unwrap();
780 assert_eq!(row.source_kind, SourceKind::Hub);
781 assert_eq!(row.trust_level, SkillTrustLevel::Quarantined);
782
783 store
785 .upsert_skill_trust(
786 "git",
787 SkillTrustLevel::Trusted,
788 SourceKind::Bundled,
789 None,
790 None,
791 "hash1",
792 )
793 .await
794 .unwrap();
795
796 let row = store.load_skill_trust("git").await.unwrap().unwrap();
797 assert_eq!(row.source_kind, SourceKind::Bundled);
798 assert_eq!(row.trust_level, SkillTrustLevel::Trusted);
799 }
800
801 #[tokio::test]
804 async fn operator_blocked_bundled_skill_stays_blocked_when_upserted_with_blocked() {
805 let store = test_store().await;
806
807 store
809 .upsert_skill_trust(
810 "web-search",
811 SkillTrustLevel::Blocked,
812 SourceKind::Hub,
813 None,
814 None,
815 "hash1",
816 )
817 .await
818 .unwrap();
819
820 store
822 .upsert_skill_trust(
823 "web-search",
824 SkillTrustLevel::Blocked,
825 SourceKind::Bundled,
826 None,
827 None,
828 "hash1",
829 )
830 .await
831 .unwrap();
832
833 let row = store.load_skill_trust("web-search").await.unwrap().unwrap();
834 assert_eq!(row.source_kind, SourceKind::Bundled);
835 assert_eq!(
836 row.trust_level,
837 SkillTrustLevel::Blocked,
838 "operator block must survive source_kind migration"
839 );
840 }
841
842 #[tokio::test]
845 async fn bundled_skill_with_configured_quarantined_level() {
846 let store = test_store().await;
847
848 store
849 .upsert_skill_trust(
850 "git",
851 SkillTrustLevel::Quarantined,
852 SourceKind::Bundled,
853 None,
854 None,
855 "hash1",
856 )
857 .await
858 .unwrap();
859
860 let row = store.load_skill_trust("git").await.unwrap().unwrap();
861 assert_eq!(row.source_kind, SourceKind::Bundled);
862 assert_eq!(row.trust_level, SkillTrustLevel::Quarantined);
863 }
864}