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