1use crate::core::types::{Category, ValidationTier};
27use chrono::{DateTime, Utc};
28use serde::{Deserialize, Serialize};
29use sha2::{Digest, Sha256};
30
31pub const CURRENT_SCHEMA_VERSION: u32 = 2;
33
34pub const MIN_SCHEMA_VERSION: u32 = 1;
36
37const MANIFEST_SIGNING_KEY: &[u8] = b"rusty-stack-manifest-signing-key-v1";
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct ManifestComponent {
48 pub id: String,
49 pub version: String,
50 pub script: String,
51 pub category: Category,
52 #[serde(default = "default_validation_tier")]
53 pub validation_tier: ValidationTier,
54 #[serde(default)]
56 pub min_rocm_version: String,
57 #[serde(default)]
59 pub compatible_channels: Vec<String>,
60 #[serde(default)]
62 pub dependencies: Vec<String>,
63 #[serde(default)]
72 pub exclusive_group: String,
73}
74
75fn default_validation_tier() -> ValidationTier {
76 ValidationTier::Candidate
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88pub struct Manifest {
89 pub schema_version: u32,
90 #[serde(default)]
92 pub sequence: u64,
93 #[serde(default)]
95 pub generated_at: String,
96 #[serde(default)]
99 pub expires_at: Option<String>,
100 #[serde(default)]
102 pub min_runtime_version: String,
103 pub components: Vec<ManifestComponent>,
105 #[serde(default)]
107 pub signature: Option<String>,
108}
109
110impl Manifest {
111 pub fn from_json(json: &str) -> anyhow::Result<Self> {
113 let manifest: Manifest = serde_json::from_str(json)?;
114 Ok(manifest)
115 }
116
117 pub fn to_json(&self) -> anyhow::Result<String> {
119 let json = serde_json::to_string(self)?;
120 Ok(json)
121 }
122
123 pub fn load_baseline() -> anyhow::Result<Self> {
125 let json = include_str!("fixtures/baseline_manifest.json");
126 Self::from_json(json)
127 }
128
129 pub fn compute_signature(&self) -> String {
135 let body = self.signatureless_json();
136 let mut hasher = Sha256::new();
137 sha2::Digest::update(&mut hasher, MANIFEST_SIGNING_KEY);
138 sha2::Digest::update(&mut hasher, body.as_bytes());
139 let result = hasher.finalize();
140 result
141 .iter()
142 .map(|b| format!("{:02x}", b))
143 .collect::<String>()
144 }
145
146 pub fn verify_signature(&self) -> Result<(), ManifestTrustError> {
151 match &self.signature {
152 None => Err(ManifestTrustError::SignatureVerificationError(
153 "missing signature".to_string(),
154 )),
155 Some(sig) => {
156 let expected = self.compute_signature();
157 if sig == &expected {
158 Ok(())
159 } else {
160 Err(ManifestTrustError::SignatureVerificationError(
161 "signature mismatch".to_string(),
162 ))
163 }
164 }
165 }
166 }
167
168 pub fn verify_schema_version(&self) -> Result<(), ManifestTrustError> {
172 if self.schema_version < MIN_SCHEMA_VERSION {
173 Err(ManifestTrustError::SchemaVersionError {
174 version: self.schema_version,
175 minimum: MIN_SCHEMA_VERSION,
176 })
177 } else {
178 Ok(())
179 }
180 }
181
182 pub fn verify_expiry(&self) -> Result<(), ManifestTrustError> {
187 match &self.expires_at {
188 None => Ok(()),
189 Some(expiry_str) => {
190 let expiry: DateTime<Utc> =
191 expiry_str
192 .parse()
193 .map_err(|e| ManifestTrustError::ManifestExpiredError {
194 expires_at: expiry_str.clone(),
195 reason: format!("invalid timestamp: {e}"),
196 })?;
197 if Utc::now() > expiry {
198 Err(ManifestTrustError::ManifestExpiredError {
199 expires_at: expiry_str.clone(),
200 reason: "manifest has expired".to_string(),
201 })
202 } else {
203 Ok(())
204 }
205 }
206 }
207 }
208
209 pub fn verify_trust(&self) -> Result<(), ManifestTrustError> {
213 self.verify_signature()?;
214 self.verify_schema_version()?;
215 self.verify_expiry()?;
216 Ok(())
217 }
218
219 fn signatureless_json(&self) -> String {
221 let mut copy = self.clone();
223 copy.signature = None;
224 serde_json::to_string(©).unwrap_or_default()
225 }
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
242pub struct ManifestOverlay {
243 pub components: Vec<ManifestComponent>,
245}
246
247impl ManifestOverlay {
248 pub fn from_json(json: &str) -> anyhow::Result<Self> {
250 let overlay: ManifestOverlay = serde_json::from_str(json)?;
251 Ok(overlay)
252 }
253
254 pub fn merge_onto(&self, baseline: &Manifest) -> Manifest {
259 let mut merged = baseline.clone();
260
261 for overlay_comp in &self.components {
262 if let Some(existing) = merged
263 .components
264 .iter_mut()
265 .find(|c| c.id == overlay_comp.id)
266 {
267 *existing = overlay_comp.clone();
269 } else {
270 merged.components.push(overlay_comp.clone());
272 }
273 }
274
275 merged
276 }
277}
278
279#[derive(Debug, Clone, PartialEq)]
285pub enum ManifestTrustError {
286 SignatureVerificationError(String),
288 SchemaVersionError { version: u32, minimum: u32 },
290 ManifestExpiredError { expires_at: String, reason: String },
292}
293
294impl std::fmt::Display for ManifestTrustError {
295 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296 match self {
297 ManifestTrustError::SignatureVerificationError(msg) => {
298 write!(f, "signature verification failed: {msg}")
299 }
300 ManifestTrustError::SchemaVersionError { version, minimum } => {
301 write!(
302 f,
303 "schema version {version} is below minimum supported version {minimum}"
304 )
305 }
306 ManifestTrustError::ManifestExpiredError { expires_at, reason } => {
307 write!(f, "manifest expired at {expires_at}: {reason}")
308 }
309 }
310 }
311}
312
313impl std::error::Error for ManifestTrustError {}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321pub enum ManifestSource {
322 FreshRemote,
324 CachedRemote,
326 Baseline,
328}
329
330#[derive(Debug, Clone)]
332pub struct ResolvedManifest {
333 pub manifest: Manifest,
334 pub source: ManifestSource,
335}
336
337pub trait ManifestFetcher {
340 fn fetch_remote(&self) -> Option<Manifest>;
343
344 fn load_cached(&self) -> Option<Manifest>;
347}
348
349pub fn resolve_manifest(fetcher: &dyn ManifestFetcher) -> ResolvedManifest {
358 if let Some(remote) = fetcher.fetch_remote() {
360 if remote.verify_trust().is_ok() {
361 return ResolvedManifest {
362 manifest: remote,
363 source: ManifestSource::FreshRemote,
364 };
365 }
366 }
367
368 if let Some(cached) = fetcher.load_cached() {
370 if cached.verify_trust().is_ok() {
371 return ResolvedManifest {
372 manifest: cached,
373 source: ManifestSource::CachedRemote,
374 };
375 }
376 }
377
378 let baseline = Manifest::load_baseline().expect("bundled baseline must be valid");
380 ResolvedManifest {
381 manifest: baseline,
382 source: ManifestSource::Baseline,
383 }
384}
385
386pub fn create_signed_manifest(
392 schema_version: u32,
393 sequence: u64,
394 expires_at: Option<String>,
395 components: Vec<ManifestComponent>,
396) -> Manifest {
397 let mut manifest = Manifest {
398 schema_version,
399 sequence,
400 generated_at: Utc::now().to_rfc3339(),
401 expires_at,
402 min_runtime_version: String::new(),
403 components,
404 signature: None,
405 };
406 manifest.signature = Some(manifest.compute_signature());
407 manifest
408}
409
410#[cfg(test)]
415mod tests {
416 use super::*;
417
418 fn make_component(id: &str, version: &str) -> ManifestComponent {
420 ManifestComponent {
421 id: id.to_string(),
422 version: version.to_string(),
423 script: format!("scripts/install_{id}.sh"),
424 category: Category::Core,
425 validation_tier: ValidationTier::Validated,
426 min_rocm_version: String::new(),
427 compatible_channels: vec![],
428 dependencies: vec![],
429 exclusive_group: String::new(),
430 }
431 }
432
433 #[test]
436 fn test_manifest_baseline_loads_with_non_empty_components() {
437 let baseline = Manifest::load_baseline().expect("baseline should load");
438 assert!(
439 !baseline.components.is_empty(),
440 "baseline must have non-empty components"
441 );
442 assert_eq!(baseline.schema_version, CURRENT_SCHEMA_VERSION);
443 }
444
445 #[test]
446 fn test_manifest_baseline_every_component_has_valid_fields() {
447 let baseline = Manifest::load_baseline().expect("baseline should load");
448 for comp in &baseline.components {
449 assert!(
450 !comp.id.is_empty(),
451 "component id must be non-empty, got empty"
452 );
453 assert!(
454 !comp.script.is_empty(),
455 "component script must be non-empty for id '{}'",
456 comp.id
457 );
458 assert!(
460 Category::from_label(&comp.category.to_string()).is_some(),
461 "component '{}' has invalid category",
462 comp.id
463 );
464 assert!(
466 comp.dependencies.iter().all(|s| !s.is_empty()),
467 "component '{}' has empty dependency string",
468 comp.id
469 );
470 }
471 }
472
473 #[test]
474 fn test_manifest_baseline_schema_version_matches_constant() {
475 let baseline = Manifest::load_baseline().expect("baseline should load");
476 assert_eq!(baseline.schema_version, CURRENT_SCHEMA_VERSION);
477 }
478
479 #[test]
480 fn test_manifest_baseline_sequence_is_positive() {
481 let baseline = Manifest::load_baseline().expect("baseline should load");
482 assert!(baseline.sequence > 0, "baseline sequence must be positive");
483 }
484
485 #[test]
488 fn test_manifest_overlay_merges_onto_baseline_deterministically() {
489 let baseline = Manifest {
490 schema_version: 2,
491 sequence: 1,
492 generated_at: "2026-01-01T00:00:00Z".to_string(),
493 expires_at: None,
494 min_runtime_version: String::new(),
495 components: vec![
496 make_component("rocm", "7.2.1"),
497 make_component("pytorch", "2.6.0"),
498 make_component("triton", "3.2.0"),
499 ],
500 signature: None,
501 };
502
503 let overlay = ManifestOverlay {
504 components: vec![
505 ManifestComponent {
507 id: "pytorch".to_string(),
508 version: "2.7.0".to_string(),
509 script: "scripts/install_pytorch_rocm.sh".to_string(),
510 category: Category::Core,
511 validation_tier: ValidationTier::Validated,
512 min_rocm_version: String::new(),
513 compatible_channels: vec![],
514 dependencies: vec![],
515 exclusive_group: String::new(),
516 },
517 ManifestComponent {
519 id: "vllm".to_string(),
520 version: "0.9.0".to_string(),
521 script: "scripts/install_vllm.sh".to_string(),
522 category: Category::Extension,
523 validation_tier: ValidationTier::Candidate,
524 min_rocm_version: String::new(),
525 compatible_channels: vec![],
526 dependencies: vec![],
527 exclusive_group: String::new(),
528 },
529 ],
530 };
531
532 let merged = overlay.merge_onto(&baseline);
533
534 assert_eq!(merged.components.len(), 4);
536
537 let rocm = merged.components.iter().find(|c| c.id == "rocm").unwrap();
539 assert_eq!(rocm.version, "7.2.1");
540
541 let pytorch = merged
543 .components
544 .iter()
545 .find(|c| c.id == "pytorch")
546 .unwrap();
547 assert_eq!(pytorch.version, "2.7.0");
548
549 let triton = merged.components.iter().find(|c| c.id == "triton").unwrap();
551 assert_eq!(triton.version, "3.2.0");
552
553 let vllm = merged.components.iter().find(|c| c.id == "vllm").unwrap();
555 assert_eq!(vllm.version, "0.9.0");
556 assert_eq!(vllm.category, Category::Extension);
557 }
558
559 #[test]
560 fn test_manifest_overlay_empty_overlay_preserves_baseline() {
561 let baseline = Manifest {
562 schema_version: 2,
563 sequence: 1,
564 generated_at: String::new(),
565 expires_at: None,
566 min_runtime_version: String::new(),
567 components: vec![make_component("rocm", "7.2.1")],
568 signature: None,
569 };
570
571 let overlay = ManifestOverlay { components: vec![] };
572
573 let merged = overlay.merge_onto(&baseline);
574 assert_eq!(merged.components.len(), 1);
575 assert_eq!(merged.components[0].id, "rocm");
576 }
577
578 #[test]
579 fn test_manifest_overlay_preserves_baseline_metadata() {
580 let baseline = Manifest {
581 schema_version: 2,
582 sequence: 42,
583 generated_at: "2026-01-01T00:00:00Z".to_string(),
584 expires_at: Some("2027-01-01T00:00:00Z".to_string()),
585 min_runtime_version: String::new(),
586 components: vec![make_component("rocm", "7.2.1")],
587 signature: None,
588 };
589
590 let overlay = ManifestOverlay {
591 components: vec![make_component("pytorch", "2.7.0")],
592 };
593
594 let merged = overlay.merge_onto(&baseline);
595 assert_eq!(merged.schema_version, 2);
596 assert_eq!(merged.sequence, 42);
597 assert_eq!(merged.generated_at, "2026-01-01T00:00:00Z");
598 assert_eq!(merged.expires_at, Some("2027-01-01T00:00:00Z".to_string()));
599 }
600
601 #[test]
604 fn test_manifest_signature_accepts_valid_signature() {
605 let manifest = create_signed_manifest(
606 CURRENT_SCHEMA_VERSION,
607 1,
608 None,
609 vec![make_component("rocm", "7.2.1")],
610 );
611 assert!(
612 manifest.verify_signature().is_ok(),
613 "valid signature should be accepted"
614 );
615 }
616
617 #[test]
618 fn test_manifest_signature_rejects_tampered_manifest() {
619 let mut manifest = create_signed_manifest(
620 CURRENT_SCHEMA_VERSION,
621 1,
622 None,
623 vec![make_component("rocm", "7.2.1")],
624 );
625
626 manifest.components[0].version = "99.99.99".to_string();
628
629 let result = manifest.verify_signature();
630 assert!(result.is_err(), "tampered manifest should be rejected");
631 match result {
632 Err(ManifestTrustError::SignatureVerificationError(_)) => {}
633 other => panic!("expected SignatureVerificationError, got {:?}", other),
634 }
635 }
636
637 #[test]
638 fn test_manifest_signature_rejects_missing_signature() {
639 let manifest = Manifest {
640 schema_version: CURRENT_SCHEMA_VERSION,
641 sequence: 1,
642 generated_at: String::new(),
643 expires_at: None,
644 min_runtime_version: String::new(),
645 components: vec![make_component("rocm", "7.2.1")],
646 signature: None,
647 };
648
649 let result = manifest.verify_signature();
650 assert!(result.is_err(), "missing signature should be rejected");
651 match result {
652 Err(ManifestTrustError::SignatureVerificationError(msg)) => {
653 assert!(msg.contains("missing"), "error should mention missing");
654 }
655 other => panic!("expected SignatureVerificationError, got {:?}", other),
656 }
657 }
658
659 #[test]
660 fn test_manifest_signature_rejects_wrong_signature() {
661 let mut manifest = create_signed_manifest(
662 CURRENT_SCHEMA_VERSION,
663 1,
664 None,
665 vec![make_component("rocm", "7.2.1")],
666 );
667 manifest.signature = Some("badsignature".to_string());
668
669 let result = manifest.verify_signature();
670 assert!(result.is_err(), "wrong signature should be rejected");
671 }
672
673 #[test]
676 fn test_manifest_schema_version_rejects_old_version() {
677 let manifest = create_signed_manifest(0, 1, None, vec![make_component("rocm", "7.2.1")]);
678
679 let result = manifest.verify_schema_version();
680 assert!(result.is_err(), "schema version 0 should be rejected");
681 match result {
682 Err(ManifestTrustError::SchemaVersionError { version, minimum }) => {
683 assert_eq!(version, 0);
684 assert_eq!(minimum, MIN_SCHEMA_VERSION);
685 }
686 other => panic!("expected SchemaVersionError, got {:?}", other),
687 }
688 }
689
690 #[test]
691 fn test_manifest_schema_version_accepts_current_version() {
692 let manifest = create_signed_manifest(
693 CURRENT_SCHEMA_VERSION,
694 1,
695 None,
696 vec![make_component("rocm", "7.2.1")],
697 );
698 assert!(
699 manifest.verify_schema_version().is_ok(),
700 "current schema version should be accepted"
701 );
702 }
703
704 #[test]
705 fn test_manifest_schema_version_accepts_minimum_version() {
706 let manifest = create_signed_manifest(
707 MIN_SCHEMA_VERSION,
708 1,
709 None,
710 vec![make_component("rocm", "7.2.1")],
711 );
712 assert!(
713 manifest.verify_schema_version().is_ok(),
714 "minimum schema version should be accepted"
715 );
716 }
717
718 #[test]
719 fn test_manifest_schema_version_accepts_future_version() {
720 let manifest = create_signed_manifest(
721 CURRENT_SCHEMA_VERSION + 10,
722 1,
723 None,
724 vec![make_component("rocm", "7.2.1")],
725 );
726 assert!(
727 manifest.verify_schema_version().is_ok(),
728 "future schema version should be accepted"
729 );
730 }
731
732 #[test]
735 fn test_manifest_expiry_rejects_expired_manifest() {
736 let one_hour_ago = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
738 let manifest = create_signed_manifest(
739 CURRENT_SCHEMA_VERSION,
740 1,
741 Some(one_hour_ago),
742 vec![make_component("rocm", "7.2.1")],
743 );
744
745 let result = manifest.verify_expiry();
746 assert!(result.is_err(), "expired manifest should be rejected");
747 match result {
748 Err(ManifestTrustError::ManifestExpiredError { reason, .. }) => {
749 assert!(reason.contains("expired"), "reason should mention expired");
750 }
751 other => panic!("expected ManifestExpiredError, got {:?}", other),
752 }
753 }
754
755 #[test]
756 fn test_manifest_expiry_accepts_future_expiry() {
757 let future = (Utc::now() + chrono::Duration::days(365)).to_rfc3339();
759 let manifest = create_signed_manifest(
760 CURRENT_SCHEMA_VERSION,
761 1,
762 Some(future),
763 vec![make_component("rocm", "7.2.1")],
764 );
765 assert!(
766 manifest.verify_expiry().is_ok(),
767 "future expiry should be accepted"
768 );
769 }
770
771 #[test]
772 fn test_manifest_expiry_accepts_no_expiry() {
773 let manifest = create_signed_manifest(
774 CURRENT_SCHEMA_VERSION,
775 1,
776 None,
777 vec![make_component("rocm", "7.2.1")],
778 );
779 assert!(
780 manifest.verify_expiry().is_ok(),
781 "no expiry should be accepted"
782 );
783 }
784
785 struct MockFetcher {
789 remote: Option<Manifest>,
790 cached: Option<Manifest>,
791 }
792
793 impl ManifestFetcher for MockFetcher {
794 fn fetch_remote(&self) -> Option<Manifest> {
795 self.remote.clone()
796 }
797
798 fn load_cached(&self) -> Option<Manifest> {
799 self.cached.clone()
800 }
801 }
802
803 #[test]
804 fn test_manifest_fallback_returns_fresh_remote_when_available() {
805 let remote = create_signed_manifest(
806 CURRENT_SCHEMA_VERSION,
807 10,
808 None,
809 vec![make_component("rocm", "7.3.0")],
810 );
811
812 let fetcher = MockFetcher {
813 remote: Some(remote.clone()),
814 cached: None,
815 };
816
817 let resolved = resolve_manifest(&fetcher);
818 assert_eq!(resolved.source, ManifestSource::FreshRemote);
819 assert_eq!(resolved.manifest.sequence, 10);
820 }
821
822 #[test]
823 fn test_manifest_fallback_returns_cached_when_remote_fails() {
824 let cached = create_signed_manifest(
825 CURRENT_SCHEMA_VERSION,
826 5,
827 None,
828 vec![make_component("rocm", "7.2.1")],
829 );
830
831 let fetcher = MockFetcher {
832 remote: None,
833 cached: Some(cached.clone()),
834 };
835
836 let resolved = resolve_manifest(&fetcher);
837 assert_eq!(resolved.source, ManifestSource::CachedRemote);
838 assert_eq!(resolved.manifest.sequence, 5);
839 }
840
841 #[test]
842 fn test_manifest_fallback_returns_cached_when_remote_trust_fails() {
843 let mut bad_remote = create_signed_manifest(
845 CURRENT_SCHEMA_VERSION,
846 10,
847 None,
848 vec![make_component("rocm", "7.3.0")],
849 );
850 bad_remote.signature = Some("bad".to_string());
851
852 let cached = create_signed_manifest(
853 CURRENT_SCHEMA_VERSION,
854 5,
855 None,
856 vec![make_component("rocm", "7.2.1")],
857 );
858
859 let fetcher = MockFetcher {
860 remote: Some(bad_remote),
861 cached: Some(cached),
862 };
863
864 let resolved = resolve_manifest(&fetcher);
865 assert_eq!(resolved.source, ManifestSource::CachedRemote);
866 }
867
868 #[test]
869 fn test_manifest_fallback_returns_baseline_when_both_fail() {
870 let fetcher = MockFetcher {
871 remote: None,
872 cached: None,
873 };
874
875 let resolved = resolve_manifest(&fetcher);
876 assert_eq!(resolved.source, ManifestSource::Baseline);
877 assert!(
878 !resolved.manifest.components.is_empty(),
879 "baseline must have components"
880 );
881 }
882
883 #[test]
884 fn test_manifest_fallback_baseline_never_errors() {
885 let fetcher = MockFetcher {
887 remote: None,
888 cached: None,
889 };
890
891 let resolved = resolve_manifest(&fetcher);
892 assert_eq!(resolved.source, ManifestSource::Baseline);
893 }
894
895 #[test]
898 fn test_manifest_verify_trust_all_pass() {
899 let manifest = create_signed_manifest(
900 CURRENT_SCHEMA_VERSION,
901 1,
902 None,
903 vec![make_component("rocm", "7.2.1")],
904 );
905 assert!(manifest.verify_trust().is_ok());
906 }
907
908 #[test]
909 fn test_manifest_verify_trust_fails_on_bad_signature() {
910 let mut manifest = create_signed_manifest(
911 CURRENT_SCHEMA_VERSION,
912 1,
913 None,
914 vec![make_component("rocm", "7.2.1")],
915 );
916 manifest.signature = Some("bad".to_string());
917 assert!(manifest.verify_trust().is_err());
918 }
919
920 #[test]
921 fn test_manifest_verify_trust_fails_on_old_schema() {
922 let manifest = create_signed_manifest(0, 1, None, vec![make_component("rocm", "7.2.1")]);
923 assert!(manifest.verify_trust().is_err());
924 }
925
926 #[test]
927 fn test_manifest_verify_trust_fails_on_expired() {
928 let one_hour_ago = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
929 let manifest = create_signed_manifest(
930 CURRENT_SCHEMA_VERSION,
931 1,
932 Some(one_hour_ago),
933 vec![make_component("rocm", "7.2.1")],
934 );
935 assert!(manifest.verify_trust().is_err());
936 }
937
938 #[test]
941 fn test_manifest_serde_roundtrip() {
942 let manifest = create_signed_manifest(
943 CURRENT_SCHEMA_VERSION,
944 42,
945 Some("2027-01-01T00:00:00Z".to_string()),
946 vec![
947 make_component("rocm", "7.2.1"),
948 ManifestComponent {
949 id: "pytorch".to_string(),
950 version: "2.6.0".to_string(),
951 script: "scripts/install_pytorch_rocm.sh".to_string(),
952 category: Category::Core,
953 validation_tier: ValidationTier::Validated,
954 min_rocm_version: String::new(),
955 compatible_channels: vec![],
956 dependencies: vec![],
957 exclusive_group: String::new(),
958 },
959 ],
960 );
961
962 let json = manifest.to_json().unwrap();
963 let back: Manifest = Manifest::from_json(&json).unwrap();
964 assert_eq!(manifest, back);
965 }
966
967 #[test]
968 fn test_manifest_overlay_serde_roundtrip() {
969 let overlay = ManifestOverlay {
970 components: vec![
971 make_component("rocm", "7.3.0"),
972 ManifestComponent {
973 id: "new-comp".to_string(),
974 version: "1.0.0".to_string(),
975 script: "scripts/install_new.sh".to_string(),
976 category: Category::Extension,
977 validation_tier: ValidationTier::Candidate,
978 min_rocm_version: String::new(),
979 compatible_channels: vec![],
980 dependencies: vec![],
981 exclusive_group: String::new(),
982 },
983 ],
984 };
985
986 let json = serde_json::to_string(&overlay).unwrap();
987 let back: ManifestOverlay = serde_json::from_str(&json).unwrap();
988 assert_eq!(overlay, back);
989 }
990
991 #[test]
992 fn test_manifest_error_display() {
993 let err = ManifestTrustError::SignatureVerificationError("test".to_string());
994 assert!(err.to_string().contains("test"));
995
996 let err = ManifestTrustError::SchemaVersionError {
997 version: 0,
998 minimum: 1,
999 };
1000 assert!(err.to_string().contains("0"));
1001 assert!(err.to_string().contains("1"));
1002
1003 let err = ManifestTrustError::ManifestExpiredError {
1004 expires_at: "2026-01-01T00:00:00Z".to_string(),
1005 reason: "expired".to_string(),
1006 };
1007 assert!(err.to_string().contains("2026-01-01"));
1008 }
1009}