1use serde::{Deserialize, Serialize};
5#[allow(unused_imports)]
6use zeph_db::sql;
7
8use super::SqliteStore;
9use crate::error::MemoryError;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum SourceKind {
15 Local,
16 Hub,
17 File,
18 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 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 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 #[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 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 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 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 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 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 let result: Result<SourceKind, _> = "future_variant".parse();
598 assert!(result.is_err());
599 let fallback = result.unwrap_or(SourceKind::Local);
601 assert_eq!(fallback, SourceKind::Local);
602 }
603
604 #[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 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 #[tokio::test]
643 async fn migration_hub_quarantined_to_bundled_trusted() {
644 let store = test_store().await;
645
646 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 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 #[tokio::test]
670 async fn operator_blocked_bundled_skill_stays_blocked_when_upserted_with_blocked() {
671 let store = test_store().await;
672
673 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 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 #[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}