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 = (
73 String,
74 String,
75 String,
76 Option<String>,
77 Option<String>,
78 String,
79 String,
80 Option<String>,
81 i64,
82);
83
84fn row_from_tuple(t: TrustTuple) -> SkillTrustRow {
85 let source_kind = t.2.parse::<SourceKind>().unwrap_or(SourceKind::Local);
86 let trust_level = t.1.parse::<SkillTrustLevel>().unwrap_or_default();
87 SkillTrustRow {
88 skill_name: t.0,
89 trust_level,
90 source_kind,
91 source_url: t.3,
92 source_path: t.4,
93 blake3_hash: t.5,
94 updated_at: t.6,
95 git_hash: t.7,
96 requires_trust_check: t.8 != 0,
97 }
98}
99
100impl SqliteStore {
101 pub async fn upsert_skill_trust(
107 &self,
108 skill_name: &str,
109 trust_level: SkillTrustLevel,
110 source_kind: SourceKind,
111 source_url: Option<&str>,
112 source_path: Option<&str>,
113 blake3_hash: &str,
114 ) -> Result<(), MemoryError> {
115 self.upsert_skill_trust_with_git_hash(
116 skill_name,
117 trust_level,
118 source_kind,
119 source_url,
120 source_path,
121 blake3_hash,
122 None,
123 )
124 .await
125 }
126
127 #[allow(clippy::too_many_arguments)] pub async fn upsert_skill_trust_with_git_hash(
138 &self,
139 skill_name: &str,
140 trust_level: SkillTrustLevel,
141 source_kind: SourceKind,
142 source_url: Option<&str>,
143 source_path: Option<&str>,
144 blake3_hash: &str,
145 git_hash: Option<&str>,
146 ) -> Result<(), MemoryError> {
147 zeph_db::query(
148 sql!("INSERT INTO skill_trust \
149 (skill_name, trust_level, source_kind, source_url, source_path, blake3_hash, git_hash, updated_at) \
150 VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) \
151 ON CONFLICT(skill_name) DO UPDATE SET \
152 trust_level = excluded.trust_level, \
153 source_kind = excluded.source_kind, \
154 source_url = excluded.source_url, \
155 source_path = excluded.source_path, \
156 blake3_hash = excluded.blake3_hash, \
157 git_hash = excluded.git_hash, \
158 updated_at = CURRENT_TIMESTAMP"),
159 )
160 .bind(skill_name)
161 .bind(trust_level.as_str())
162 .bind(source_kind.as_str())
163 .bind(source_url)
164 .bind(source_path)
165 .bind(blake3_hash)
166 .bind(git_hash)
167 .execute(&self.pool)
168 .await?;
169 Ok(())
170 }
171
172 pub async fn load_skill_trust(
178 &self,
179 skill_name: &str,
180 ) -> Result<Option<SkillTrustRow>, MemoryError> {
181 let row: Option<TrustTuple> = zeph_db::query_as(sql!(
182 "SELECT skill_name, trust_level, source_kind, source_url, source_path, \
183 blake3_hash, updated_at, git_hash, requires_trust_check \
184 FROM skill_trust WHERE skill_name = ?"
185 ))
186 .bind(skill_name)
187 .fetch_optional(&self.pool)
188 .await?;
189 Ok(row.map(row_from_tuple))
190 }
191
192 pub async fn load_all_skill_trust(&self) -> Result<Vec<SkillTrustRow>, MemoryError> {
198 let rows: Vec<TrustTuple> = zeph_db::query_as(sql!(
199 "SELECT skill_name, trust_level, source_kind, source_url, source_path, \
200 blake3_hash, updated_at, git_hash, requires_trust_check \
201 FROM skill_trust ORDER BY skill_name"
202 ))
203 .fetch_all(&self.pool)
204 .await?;
205 Ok(rows.into_iter().map(row_from_tuple).collect())
206 }
207
208 pub async fn set_skill_trust_level(
214 &self,
215 skill_name: &str,
216 trust_level: SkillTrustLevel,
217 ) -> Result<bool, MemoryError> {
218 let result = zeph_db::query(
219 sql!("UPDATE skill_trust SET trust_level = ?, updated_at = CURRENT_TIMESTAMP WHERE skill_name = ?"),
220 )
221 .bind(trust_level.as_str())
222 .bind(skill_name)
223 .execute(&self.pool)
224 .await?;
225 Ok(result.rows_affected() > 0)
226 }
227
228 pub async fn delete_skill_trust(&self, skill_name: &str) -> Result<bool, MemoryError> {
234 let result = zeph_db::query(sql!("DELETE FROM skill_trust WHERE skill_name = ?"))
235 .bind(skill_name)
236 .execute(&self.pool)
237 .await?;
238 Ok(result.rows_affected() > 0)
239 }
240
241 pub async fn set_requires_trust_check(
250 &self,
251 skill_name: &str,
252 enabled: bool,
253 ) -> Result<bool, MemoryError> {
254 let flag = i64::from(enabled);
255 let result = zeph_db::query(
256 sql!("UPDATE skill_trust SET requires_trust_check = ?, updated_at = CURRENT_TIMESTAMP WHERE skill_name = ?"),
257 )
258 .bind(flag)
259 .bind(skill_name)
260 .execute(&self.pool)
261 .await?;
262 Ok(result.rows_affected() > 0)
263 }
264
265 pub async fn update_skill_hash(
271 &self,
272 skill_name: &str,
273 blake3_hash: &str,
274 ) -> Result<bool, MemoryError> {
275 let result = zeph_db::query(
276 sql!("UPDATE skill_trust SET blake3_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE skill_name = ?"),
277 )
278 .bind(blake3_hash)
279 .bind(skill_name)
280 .execute(&self.pool)
281 .await?;
282 Ok(result.rows_affected() > 0)
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 async fn test_store() -> SqliteStore {
291 SqliteStore::new(":memory:").await.unwrap()
292 }
293
294 #[tokio::test]
295 async fn upsert_and_load() {
296 let store = test_store().await;
297
298 store
299 .upsert_skill_trust(
300 "git",
301 SkillTrustLevel::Trusted,
302 SourceKind::Local,
303 None,
304 None,
305 "abc123",
306 )
307 .await
308 .unwrap();
309
310 let row = store.load_skill_trust("git").await.unwrap().unwrap();
311 assert_eq!(row.skill_name, "git");
312 assert_eq!(row.trust_level, SkillTrustLevel::Trusted);
313 assert_eq!(row.source_kind, SourceKind::Local);
314 assert_eq!(row.blake3_hash, "abc123");
315 }
316
317 #[tokio::test]
318 async fn upsert_updates_existing() {
319 let store = test_store().await;
320
321 store
322 .upsert_skill_trust(
323 "git",
324 SkillTrustLevel::Quarantined,
325 SourceKind::Local,
326 None,
327 None,
328 "hash1",
329 )
330 .await
331 .unwrap();
332 store
333 .upsert_skill_trust(
334 "git",
335 SkillTrustLevel::Trusted,
336 SourceKind::Local,
337 None,
338 None,
339 "hash2",
340 )
341 .await
342 .unwrap();
343
344 let row = store.load_skill_trust("git").await.unwrap().unwrap();
345 assert_eq!(row.trust_level, SkillTrustLevel::Trusted);
346 assert_eq!(row.blake3_hash, "hash2");
347 }
348
349 #[tokio::test]
350 async fn load_nonexistent() {
351 let store = test_store().await;
352 let row = store.load_skill_trust("nope").await.unwrap();
353 assert!(row.is_none());
354 }
355
356 #[tokio::test]
357 async fn load_all() {
358 let store = test_store().await;
359
360 store
361 .upsert_skill_trust(
362 "alpha",
363 SkillTrustLevel::Trusted,
364 SourceKind::Local,
365 None,
366 None,
367 "h1",
368 )
369 .await
370 .unwrap();
371 store
372 .upsert_skill_trust(
373 "beta",
374 SkillTrustLevel::Quarantined,
375 SourceKind::Hub,
376 Some("https://hub.example.com"),
377 None,
378 "h2",
379 )
380 .await
381 .unwrap();
382
383 let rows = store.load_all_skill_trust().await.unwrap();
384 assert_eq!(rows.len(), 2);
385 assert_eq!(rows[0].skill_name, "alpha");
386 assert_eq!(rows[1].skill_name, "beta");
387 }
388
389 #[tokio::test]
390 async fn set_trust_level() {
391 let store = test_store().await;
392
393 store
394 .upsert_skill_trust(
395 "git",
396 SkillTrustLevel::Quarantined,
397 SourceKind::Local,
398 None,
399 None,
400 "h1",
401 )
402 .await
403 .unwrap();
404
405 let updated = store
406 .set_skill_trust_level("git", SkillTrustLevel::Blocked)
407 .await
408 .unwrap();
409 assert!(updated);
410
411 let row = store.load_skill_trust("git").await.unwrap().unwrap();
412 assert_eq!(row.trust_level, SkillTrustLevel::Blocked);
413 }
414
415 #[tokio::test]
416 async fn set_trust_level_nonexistent() {
417 let store = test_store().await;
418 let updated = store
419 .set_skill_trust_level("nope", SkillTrustLevel::Blocked)
420 .await
421 .unwrap();
422 assert!(!updated);
423 }
424
425 #[tokio::test]
426 async fn delete_trust() {
427 let store = test_store().await;
428
429 store
430 .upsert_skill_trust(
431 "git",
432 SkillTrustLevel::Trusted,
433 SourceKind::Local,
434 None,
435 None,
436 "h1",
437 )
438 .await
439 .unwrap();
440
441 let deleted = store.delete_skill_trust("git").await.unwrap();
442 assert!(deleted);
443
444 let row = store.load_skill_trust("git").await.unwrap();
445 assert!(row.is_none());
446 }
447
448 #[tokio::test]
449 async fn delete_nonexistent() {
450 let store = test_store().await;
451 let deleted = store.delete_skill_trust("nope").await.unwrap();
452 assert!(!deleted);
453 }
454
455 #[tokio::test]
456 async fn update_hash() {
457 let store = test_store().await;
458
459 store
460 .upsert_skill_trust(
461 "git",
462 SkillTrustLevel::Verified,
463 SourceKind::Local,
464 None,
465 None,
466 "old_hash",
467 )
468 .await
469 .unwrap();
470
471 let updated = store.update_skill_hash("git", "new_hash").await.unwrap();
472 assert!(updated);
473
474 let row = store.load_skill_trust("git").await.unwrap().unwrap();
475 assert_eq!(row.blake3_hash, "new_hash");
476 }
477
478 #[tokio::test]
479 async fn source_with_url() {
480 let store = test_store().await;
481
482 store
483 .upsert_skill_trust(
484 "remote-skill",
485 SkillTrustLevel::Quarantined,
486 SourceKind::Hub,
487 Some("https://hub.example.com/skill"),
488 None,
489 "h1",
490 )
491 .await
492 .unwrap();
493
494 let row = store
495 .load_skill_trust("remote-skill")
496 .await
497 .unwrap()
498 .unwrap();
499 assert_eq!(row.source_kind, SourceKind::Hub);
500 assert_eq!(
501 row.source_url.as_deref(),
502 Some("https://hub.example.com/skill")
503 );
504 }
505
506 #[tokio::test]
507 async fn source_with_path() {
508 let store = test_store().await;
509
510 store
511 .upsert_skill_trust(
512 "file-skill",
513 SkillTrustLevel::Quarantined,
514 SourceKind::File,
515 None,
516 Some("/tmp/skill.tar.gz"),
517 "h1",
518 )
519 .await
520 .unwrap();
521
522 let row = store.load_skill_trust("file-skill").await.unwrap().unwrap();
523 assert_eq!(row.source_kind, SourceKind::File);
524 assert_eq!(row.source_path.as_deref(), Some("/tmp/skill.tar.gz"));
525 }
526
527 #[test]
528 fn source_kind_display_local() {
529 assert_eq!(SourceKind::Local.to_string(), "local");
530 }
531
532 #[test]
533 fn source_kind_display_hub() {
534 assert_eq!(SourceKind::Hub.to_string(), "hub");
535 }
536
537 #[test]
538 fn source_kind_display_file() {
539 assert_eq!(SourceKind::File.to_string(), "file");
540 }
541
542 #[test]
543 fn source_kind_from_str_local() {
544 let kind: SourceKind = "local".parse().unwrap();
545 assert_eq!(kind, SourceKind::Local);
546 }
547
548 #[test]
549 fn source_kind_from_str_hub() {
550 let kind: SourceKind = "hub".parse().unwrap();
551 assert_eq!(kind, SourceKind::Hub);
552 }
553
554 #[test]
555 fn source_kind_from_str_file() {
556 let kind: SourceKind = "file".parse().unwrap();
557 assert_eq!(kind, SourceKind::File);
558 }
559
560 #[test]
561 fn source_kind_from_str_unknown_returns_error() {
562 let result: Result<SourceKind, _> = "s3".parse();
563 assert!(result.is_err());
564 assert!(result.unwrap_err().contains("unknown source_kind"));
565 }
566
567 #[test]
568 fn source_kind_serde_json_roundtrip_local() {
569 let original = SourceKind::Local;
570 let json = serde_json::to_string(&original).unwrap();
571 assert_eq!(json, r#""local""#);
572 let back: SourceKind = serde_json::from_str(&json).unwrap();
573 assert_eq!(back, original);
574 }
575
576 #[test]
577 fn source_kind_serde_json_roundtrip_hub() {
578 let original = SourceKind::Hub;
579 let json = serde_json::to_string(&original).unwrap();
580 assert_eq!(json, r#""hub""#);
581 let back: SourceKind = serde_json::from_str(&json).unwrap();
582 assert_eq!(back, original);
583 }
584
585 #[test]
586 fn source_kind_serde_json_roundtrip_file() {
587 let original = SourceKind::File;
588 let json = serde_json::to_string(&original).unwrap();
589 assert_eq!(json, r#""file""#);
590 let back: SourceKind = serde_json::from_str(&json).unwrap();
591 assert_eq!(back, original);
592 }
593
594 #[test]
595 fn source_kind_serde_json_invalid_value_errors() {
596 let result: Result<SourceKind, _> = serde_json::from_str(r#""unknown""#);
597 assert!(result.is_err());
598 }
599
600 #[tokio::test]
601 async fn trust_row_includes_git_hash() {
602 let store = test_store().await;
603
604 store
605 .upsert_skill_trust_with_git_hash(
606 "versioned-skill",
607 SkillTrustLevel::Trusted,
608 SourceKind::Hub,
609 Some("https://hub.example.com/skill"),
610 None,
611 "blake3abc",
612 Some("deadbeef1234"),
613 )
614 .await
615 .unwrap();
616
617 let row = store
618 .load_skill_trust("versioned-skill")
619 .await
620 .unwrap()
621 .unwrap();
622 assert_eq!(row.git_hash.as_deref(), Some("deadbeef1234"));
623 assert_eq!(row.blake3_hash, "blake3abc");
624 }
625
626 #[tokio::test]
627 async fn upsert_without_git_hash_leaves_it_null() {
628 let store = test_store().await;
629
630 store
631 .upsert_skill_trust(
632 "git",
633 SkillTrustLevel::Trusted,
634 SourceKind::Local,
635 None,
636 None,
637 "hash1",
638 )
639 .await
640 .unwrap();
641
642 let row = store.load_skill_trust("git").await.unwrap().unwrap();
643 assert!(row.git_hash.is_none());
644 }
645
646 #[tokio::test]
647 async fn upsert_each_source_kind_roundtrip() {
648 let store = test_store().await;
649 let variants = [
650 ("skill-local", SourceKind::Local),
651 ("skill-hub", SourceKind::Hub),
652 ("skill-file", SourceKind::File),
653 ("skill-bundled", SourceKind::Bundled),
654 ];
655 for (name, kind) in &variants {
656 store
657 .upsert_skill_trust(
658 name,
659 SkillTrustLevel::Trusted,
660 kind.clone(),
661 None,
662 None,
663 "hash",
664 )
665 .await
666 .unwrap();
667 let row = store.load_skill_trust(name).await.unwrap().unwrap();
668 assert_eq!(&row.source_kind, kind);
669 }
670 }
671
672 #[test]
673 fn source_kind_display_bundled() {
674 assert_eq!(SourceKind::Bundled.to_string(), "bundled");
675 }
676
677 #[test]
678 fn source_kind_from_str_bundled() {
679 let kind: SourceKind = "bundled".parse().unwrap();
680 assert_eq!(kind, SourceKind::Bundled);
681 }
682
683 #[test]
684 fn source_kind_serde_json_roundtrip_bundled() {
685 let original = SourceKind::Bundled;
686 let json = serde_json::to_string(&original).unwrap();
687 assert_eq!(json, r#""bundled""#);
688 let back: SourceKind = serde_json::from_str(&json).unwrap();
689 assert_eq!(back, original);
690 }
691
692 #[test]
693 fn source_kind_from_str_unknown_falls_back_to_local_in_row_from_tuple() {
694 let result: Result<SourceKind, _> = "future_variant".parse();
697 assert!(result.is_err());
698 let fallback = result.unwrap_or(SourceKind::Local);
700 assert_eq!(fallback, SourceKind::Local);
701 }
702
703 #[tokio::test]
706 async fn bundled_trust_preserved_on_same_source_kind_upsert() {
707 let store = test_store().await;
708
709 store
710 .upsert_skill_trust(
711 "web-search",
712 SkillTrustLevel::Trusted,
713 SourceKind::Bundled,
714 None,
715 None,
716 "hash1",
717 )
718 .await
719 .unwrap();
720
721 store
723 .upsert_skill_trust(
724 "web-search",
725 SkillTrustLevel::Trusted,
726 SourceKind::Bundled,
727 None,
728 None,
729 "hash1",
730 )
731 .await
732 .unwrap();
733
734 let row = store.load_skill_trust("web-search").await.unwrap().unwrap();
735 assert_eq!(row.source_kind, SourceKind::Bundled);
736 assert_eq!(row.trust_level, SkillTrustLevel::Trusted);
737 }
738
739 #[tokio::test]
742 async fn migration_hub_quarantined_to_bundled_trusted() {
743 let store = test_store().await;
744
745 store
747 .upsert_skill_trust(
748 "git",
749 SkillTrustLevel::Quarantined,
750 SourceKind::Hub,
751 None,
752 None,
753 "hash1",
754 )
755 .await
756 .unwrap();
757
758 let row = store.load_skill_trust("git").await.unwrap().unwrap();
759 assert_eq!(row.source_kind, SourceKind::Hub);
760 assert_eq!(row.trust_level, SkillTrustLevel::Quarantined);
761
762 store
764 .upsert_skill_trust(
765 "git",
766 SkillTrustLevel::Trusted,
767 SourceKind::Bundled,
768 None,
769 None,
770 "hash1",
771 )
772 .await
773 .unwrap();
774
775 let row = store.load_skill_trust("git").await.unwrap().unwrap();
776 assert_eq!(row.source_kind, SourceKind::Bundled);
777 assert_eq!(row.trust_level, SkillTrustLevel::Trusted);
778 }
779
780 #[tokio::test]
783 async fn operator_blocked_bundled_skill_stays_blocked_when_upserted_with_blocked() {
784 let store = test_store().await;
785
786 store
788 .upsert_skill_trust(
789 "web-search",
790 SkillTrustLevel::Blocked,
791 SourceKind::Hub,
792 None,
793 None,
794 "hash1",
795 )
796 .await
797 .unwrap();
798
799 store
801 .upsert_skill_trust(
802 "web-search",
803 SkillTrustLevel::Blocked,
804 SourceKind::Bundled,
805 None,
806 None,
807 "hash1",
808 )
809 .await
810 .unwrap();
811
812 let row = store.load_skill_trust("web-search").await.unwrap().unwrap();
813 assert_eq!(row.source_kind, SourceKind::Bundled);
814 assert_eq!(
815 row.trust_level,
816 SkillTrustLevel::Blocked,
817 "operator block must survive source_kind migration"
818 );
819 }
820
821 #[tokio::test]
824 async fn bundled_skill_with_configured_quarantined_level() {
825 let store = test_store().await;
826
827 store
828 .upsert_skill_trust(
829 "git",
830 SkillTrustLevel::Quarantined,
831 SourceKind::Bundled,
832 None,
833 None,
834 "hash1",
835 )
836 .await
837 .unwrap();
838
839 let row = store.load_skill_trust("git").await.unwrap().unwrap();
840 assert_eq!(row.source_kind, SourceKind::Bundled);
841 assert_eq!(row.trust_level, SkillTrustLevel::Quarantined);
842 }
843}