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}
64
65fn default_validation_tier() -> ValidationTier {
66 ValidationTier::Candidate
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
78pub struct Manifest {
79 pub schema_version: u32,
80 #[serde(default)]
82 pub sequence: u64,
83 #[serde(default)]
85 pub generated_at: String,
86 #[serde(default)]
89 pub expires_at: Option<String>,
90 #[serde(default)]
92 pub min_runtime_version: String,
93 pub components: Vec<ManifestComponent>,
95 #[serde(default)]
97 pub signature: Option<String>,
98}
99
100impl Manifest {
101 pub fn from_json(json: &str) -> anyhow::Result<Self> {
103 let manifest: Manifest = serde_json::from_str(json)?;
104 Ok(manifest)
105 }
106
107 pub fn to_json(&self) -> anyhow::Result<String> {
109 let json = serde_json::to_string(self)?;
110 Ok(json)
111 }
112
113 pub fn load_baseline() -> anyhow::Result<Self> {
115 let json = include_str!("fixtures/baseline_manifest.json");
116 Self::from_json(json)
117 }
118
119 pub fn compute_signature(&self) -> String {
125 let body = self.signatureless_json();
126 let mut hasher = Sha256::new();
127 sha2::Digest::update(&mut hasher, MANIFEST_SIGNING_KEY);
128 sha2::Digest::update(&mut hasher, body.as_bytes());
129 let result = hasher.finalize();
130 result
131 .iter()
132 .map(|b| format!("{:02x}", b))
133 .collect::<String>()
134 }
135
136 pub fn verify_signature(&self) -> Result<(), ManifestTrustError> {
141 match &self.signature {
142 None => Err(ManifestTrustError::SignatureVerificationError(
143 "missing signature".to_string(),
144 )),
145 Some(sig) => {
146 let expected = self.compute_signature();
147 if sig == &expected {
148 Ok(())
149 } else {
150 Err(ManifestTrustError::SignatureVerificationError(
151 "signature mismatch".to_string(),
152 ))
153 }
154 }
155 }
156 }
157
158 pub fn verify_schema_version(&self) -> Result<(), ManifestTrustError> {
162 if self.schema_version < MIN_SCHEMA_VERSION {
163 Err(ManifestTrustError::SchemaVersionError {
164 version: self.schema_version,
165 minimum: MIN_SCHEMA_VERSION,
166 })
167 } else {
168 Ok(())
169 }
170 }
171
172 pub fn verify_expiry(&self) -> Result<(), ManifestTrustError> {
177 match &self.expires_at {
178 None => Ok(()),
179 Some(expiry_str) => {
180 let expiry: DateTime<Utc> =
181 expiry_str
182 .parse()
183 .map_err(|e| ManifestTrustError::ManifestExpiredError {
184 expires_at: expiry_str.clone(),
185 reason: format!("invalid timestamp: {e}"),
186 })?;
187 if Utc::now() > expiry {
188 Err(ManifestTrustError::ManifestExpiredError {
189 expires_at: expiry_str.clone(),
190 reason: "manifest has expired".to_string(),
191 })
192 } else {
193 Ok(())
194 }
195 }
196 }
197 }
198
199 pub fn verify_trust(&self) -> Result<(), ManifestTrustError> {
203 self.verify_signature()?;
204 self.verify_schema_version()?;
205 self.verify_expiry()?;
206 Ok(())
207 }
208
209 fn signatureless_json(&self) -> String {
211 let mut copy = self.clone();
213 copy.signature = None;
214 serde_json::to_string(©).unwrap_or_default()
215 }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
232pub struct ManifestOverlay {
233 pub components: Vec<ManifestComponent>,
235}
236
237impl ManifestOverlay {
238 pub fn from_json(json: &str) -> anyhow::Result<Self> {
240 let overlay: ManifestOverlay = serde_json::from_str(json)?;
241 Ok(overlay)
242 }
243
244 pub fn merge_onto(&self, baseline: &Manifest) -> Manifest {
249 let mut merged = baseline.clone();
250
251 for overlay_comp in &self.components {
252 if let Some(existing) = merged
253 .components
254 .iter_mut()
255 .find(|c| c.id == overlay_comp.id)
256 {
257 *existing = overlay_comp.clone();
259 } else {
260 merged.components.push(overlay_comp.clone());
262 }
263 }
264
265 merged
266 }
267}
268
269#[derive(Debug, Clone, PartialEq)]
275pub enum ManifestTrustError {
276 SignatureVerificationError(String),
278 SchemaVersionError { version: u32, minimum: u32 },
280 ManifestExpiredError { expires_at: String, reason: String },
282}
283
284impl std::fmt::Display for ManifestTrustError {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 match self {
287 ManifestTrustError::SignatureVerificationError(msg) => {
288 write!(f, "signature verification failed: {msg}")
289 }
290 ManifestTrustError::SchemaVersionError { version, minimum } => {
291 write!(
292 f,
293 "schema version {version} is below minimum supported version {minimum}"
294 )
295 }
296 ManifestTrustError::ManifestExpiredError { expires_at, reason } => {
297 write!(f, "manifest expired at {expires_at}: {reason}")
298 }
299 }
300 }
301}
302
303impl std::error::Error for ManifestTrustError {}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub enum ManifestSource {
312 FreshRemote,
314 CachedRemote,
316 Baseline,
318}
319
320#[derive(Debug, Clone)]
322pub struct ResolvedManifest {
323 pub manifest: Manifest,
324 pub source: ManifestSource,
325}
326
327pub trait ManifestFetcher {
330 fn fetch_remote(&self) -> Option<Manifest>;
333
334 fn load_cached(&self) -> Option<Manifest>;
337}
338
339pub fn resolve_manifest(fetcher: &dyn ManifestFetcher) -> ResolvedManifest {
348 if let Some(remote) = fetcher.fetch_remote() {
350 if remote.verify_trust().is_ok() {
351 return ResolvedManifest {
352 manifest: remote,
353 source: ManifestSource::FreshRemote,
354 };
355 }
356 }
357
358 if let Some(cached) = fetcher.load_cached() {
360 if cached.verify_trust().is_ok() {
361 return ResolvedManifest {
362 manifest: cached,
363 source: ManifestSource::CachedRemote,
364 };
365 }
366 }
367
368 let baseline = Manifest::load_baseline().expect("bundled baseline must be valid");
370 ResolvedManifest {
371 manifest: baseline,
372 source: ManifestSource::Baseline,
373 }
374}
375
376pub fn create_signed_manifest(
382 schema_version: u32,
383 sequence: u64,
384 expires_at: Option<String>,
385 components: Vec<ManifestComponent>,
386) -> Manifest {
387 let mut manifest = Manifest {
388 schema_version,
389 sequence,
390 generated_at: Utc::now().to_rfc3339(),
391 expires_at,
392 min_runtime_version: String::new(),
393 components,
394 signature: None,
395 };
396 manifest.signature = Some(manifest.compute_signature());
397 manifest
398}
399
400#[cfg(test)]
405mod tests {
406 use super::*;
407
408 fn make_component(id: &str, version: &str) -> ManifestComponent {
410 ManifestComponent {
411 id: id.to_string(),
412 version: version.to_string(),
413 script: format!("scripts/install_{id}.sh"),
414 category: Category::Core,
415 validation_tier: ValidationTier::Validated,
416 min_rocm_version: String::new(),
417 compatible_channels: vec![],
418 dependencies: vec![],
419 }
420 }
421
422 #[test]
425 fn test_manifest_baseline_loads_with_non_empty_components() {
426 let baseline = Manifest::load_baseline().expect("baseline should load");
427 assert!(
428 !baseline.components.is_empty(),
429 "baseline must have non-empty components"
430 );
431 assert_eq!(baseline.schema_version, CURRENT_SCHEMA_VERSION);
432 }
433
434 #[test]
435 fn test_manifest_baseline_every_component_has_valid_fields() {
436 let baseline = Manifest::load_baseline().expect("baseline should load");
437 for comp in &baseline.components {
438 assert!(
439 !comp.id.is_empty(),
440 "component id must be non-empty, got empty"
441 );
442 assert!(
443 !comp.script.is_empty(),
444 "component script must be non-empty for id '{}'",
445 comp.id
446 );
447 assert!(
449 Category::from_label(&comp.category.to_string()).is_some(),
450 "component '{}' has invalid category",
451 comp.id
452 );
453 assert!(
455 comp.dependencies.iter().all(|s| !s.is_empty()),
456 "component '{}' has empty dependency string",
457 comp.id
458 );
459 }
460 }
461
462 #[test]
463 fn test_manifest_baseline_schema_version_matches_constant() {
464 let baseline = Manifest::load_baseline().expect("baseline should load");
465 assert_eq!(baseline.schema_version, CURRENT_SCHEMA_VERSION);
466 }
467
468 #[test]
469 fn test_manifest_baseline_sequence_is_positive() {
470 let baseline = Manifest::load_baseline().expect("baseline should load");
471 assert!(baseline.sequence > 0, "baseline sequence must be positive");
472 }
473
474 #[test]
477 fn test_manifest_overlay_merges_onto_baseline_deterministically() {
478 let baseline = Manifest {
479 schema_version: 2,
480 sequence: 1,
481 generated_at: "2026-01-01T00:00:00Z".to_string(),
482 expires_at: None,
483 min_runtime_version: String::new(),
484 components: vec![
485 make_component("rocm", "7.2.1"),
486 make_component("pytorch", "2.6.0"),
487 make_component("triton", "3.2.0"),
488 ],
489 signature: None,
490 };
491
492 let overlay = ManifestOverlay {
493 components: vec![
494 ManifestComponent {
496 id: "pytorch".to_string(),
497 version: "2.7.0".to_string(),
498 script: "scripts/install_pytorch_rocm.sh".to_string(),
499 category: Category::Core,
500 validation_tier: ValidationTier::Validated,
501 min_rocm_version: String::new(),
502 compatible_channels: vec![],
503 dependencies: vec![],
504 },
505 ManifestComponent {
507 id: "vllm".to_string(),
508 version: "0.9.0".to_string(),
509 script: "scripts/install_vllm.sh".to_string(),
510 category: Category::Extension,
511 validation_tier: ValidationTier::Candidate,
512 min_rocm_version: String::new(),
513 compatible_channels: vec![],
514 dependencies: vec![],
515 },
516 ],
517 };
518
519 let merged = overlay.merge_onto(&baseline);
520
521 assert_eq!(merged.components.len(), 4);
523
524 let rocm = merged.components.iter().find(|c| c.id == "rocm").unwrap();
526 assert_eq!(rocm.version, "7.2.1");
527
528 let pytorch = merged
530 .components
531 .iter()
532 .find(|c| c.id == "pytorch")
533 .unwrap();
534 assert_eq!(pytorch.version, "2.7.0");
535
536 let triton = merged.components.iter().find(|c| c.id == "triton").unwrap();
538 assert_eq!(triton.version, "3.2.0");
539
540 let vllm = merged.components.iter().find(|c| c.id == "vllm").unwrap();
542 assert_eq!(vllm.version, "0.9.0");
543 assert_eq!(vllm.category, Category::Extension);
544 }
545
546 #[test]
547 fn test_manifest_overlay_empty_overlay_preserves_baseline() {
548 let baseline = Manifest {
549 schema_version: 2,
550 sequence: 1,
551 generated_at: String::new(),
552 expires_at: None,
553 min_runtime_version: String::new(),
554 components: vec![make_component("rocm", "7.2.1")],
555 signature: None,
556 };
557
558 let overlay = ManifestOverlay { components: vec![] };
559
560 let merged = overlay.merge_onto(&baseline);
561 assert_eq!(merged.components.len(), 1);
562 assert_eq!(merged.components[0].id, "rocm");
563 }
564
565 #[test]
566 fn test_manifest_overlay_preserves_baseline_metadata() {
567 let baseline = Manifest {
568 schema_version: 2,
569 sequence: 42,
570 generated_at: "2026-01-01T00:00:00Z".to_string(),
571 expires_at: Some("2027-01-01T00:00:00Z".to_string()),
572 min_runtime_version: String::new(),
573 components: vec![make_component("rocm", "7.2.1")],
574 signature: None,
575 };
576
577 let overlay = ManifestOverlay {
578 components: vec![make_component("pytorch", "2.7.0")],
579 };
580
581 let merged = overlay.merge_onto(&baseline);
582 assert_eq!(merged.schema_version, 2);
583 assert_eq!(merged.sequence, 42);
584 assert_eq!(merged.generated_at, "2026-01-01T00:00:00Z");
585 assert_eq!(merged.expires_at, Some("2027-01-01T00:00:00Z".to_string()));
586 }
587
588 #[test]
591 fn test_manifest_signature_accepts_valid_signature() {
592 let manifest = create_signed_manifest(
593 CURRENT_SCHEMA_VERSION,
594 1,
595 None,
596 vec![make_component("rocm", "7.2.1")],
597 );
598 assert!(
599 manifest.verify_signature().is_ok(),
600 "valid signature should be accepted"
601 );
602 }
603
604 #[test]
605 fn test_manifest_signature_rejects_tampered_manifest() {
606 let mut manifest = create_signed_manifest(
607 CURRENT_SCHEMA_VERSION,
608 1,
609 None,
610 vec![make_component("rocm", "7.2.1")],
611 );
612
613 manifest.components[0].version = "99.99.99".to_string();
615
616 let result = manifest.verify_signature();
617 assert!(result.is_err(), "tampered manifest should be rejected");
618 match result {
619 Err(ManifestTrustError::SignatureVerificationError(_)) => {}
620 other => panic!("expected SignatureVerificationError, got {:?}", other),
621 }
622 }
623
624 #[test]
625 fn test_manifest_signature_rejects_missing_signature() {
626 let manifest = Manifest {
627 schema_version: CURRENT_SCHEMA_VERSION,
628 sequence: 1,
629 generated_at: String::new(),
630 expires_at: None,
631 min_runtime_version: String::new(),
632 components: vec![make_component("rocm", "7.2.1")],
633 signature: None,
634 };
635
636 let result = manifest.verify_signature();
637 assert!(result.is_err(), "missing signature should be rejected");
638 match result {
639 Err(ManifestTrustError::SignatureVerificationError(msg)) => {
640 assert!(msg.contains("missing"), "error should mention missing");
641 }
642 other => panic!("expected SignatureVerificationError, got {:?}", other),
643 }
644 }
645
646 #[test]
647 fn test_manifest_signature_rejects_wrong_signature() {
648 let mut manifest = create_signed_manifest(
649 CURRENT_SCHEMA_VERSION,
650 1,
651 None,
652 vec![make_component("rocm", "7.2.1")],
653 );
654 manifest.signature = Some("badsignature".to_string());
655
656 let result = manifest.verify_signature();
657 assert!(result.is_err(), "wrong signature should be rejected");
658 }
659
660 #[test]
663 fn test_manifest_schema_version_rejects_old_version() {
664 let manifest = create_signed_manifest(0, 1, None, vec![make_component("rocm", "7.2.1")]);
665
666 let result = manifest.verify_schema_version();
667 assert!(result.is_err(), "schema version 0 should be rejected");
668 match result {
669 Err(ManifestTrustError::SchemaVersionError { version, minimum }) => {
670 assert_eq!(version, 0);
671 assert_eq!(minimum, MIN_SCHEMA_VERSION);
672 }
673 other => panic!("expected SchemaVersionError, got {:?}", other),
674 }
675 }
676
677 #[test]
678 fn test_manifest_schema_version_accepts_current_version() {
679 let manifest = create_signed_manifest(
680 CURRENT_SCHEMA_VERSION,
681 1,
682 None,
683 vec![make_component("rocm", "7.2.1")],
684 );
685 assert!(
686 manifest.verify_schema_version().is_ok(),
687 "current schema version should be accepted"
688 );
689 }
690
691 #[test]
692 fn test_manifest_schema_version_accepts_minimum_version() {
693 let manifest = create_signed_manifest(
694 MIN_SCHEMA_VERSION,
695 1,
696 None,
697 vec![make_component("rocm", "7.2.1")],
698 );
699 assert!(
700 manifest.verify_schema_version().is_ok(),
701 "minimum schema version should be accepted"
702 );
703 }
704
705 #[test]
706 fn test_manifest_schema_version_accepts_future_version() {
707 let manifest = create_signed_manifest(
708 CURRENT_SCHEMA_VERSION + 10,
709 1,
710 None,
711 vec![make_component("rocm", "7.2.1")],
712 );
713 assert!(
714 manifest.verify_schema_version().is_ok(),
715 "future schema version should be accepted"
716 );
717 }
718
719 #[test]
722 fn test_manifest_expiry_rejects_expired_manifest() {
723 let one_hour_ago = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
725 let manifest = create_signed_manifest(
726 CURRENT_SCHEMA_VERSION,
727 1,
728 Some(one_hour_ago),
729 vec![make_component("rocm", "7.2.1")],
730 );
731
732 let result = manifest.verify_expiry();
733 assert!(result.is_err(), "expired manifest should be rejected");
734 match result {
735 Err(ManifestTrustError::ManifestExpiredError { reason, .. }) => {
736 assert!(reason.contains("expired"), "reason should mention expired");
737 }
738 other => panic!("expected ManifestExpiredError, got {:?}", other),
739 }
740 }
741
742 #[test]
743 fn test_manifest_expiry_accepts_future_expiry() {
744 let future = (Utc::now() + chrono::Duration::days(365)).to_rfc3339();
746 let manifest = create_signed_manifest(
747 CURRENT_SCHEMA_VERSION,
748 1,
749 Some(future),
750 vec![make_component("rocm", "7.2.1")],
751 );
752 assert!(
753 manifest.verify_expiry().is_ok(),
754 "future expiry should be accepted"
755 );
756 }
757
758 #[test]
759 fn test_manifest_expiry_accepts_no_expiry() {
760 let manifest = create_signed_manifest(
761 CURRENT_SCHEMA_VERSION,
762 1,
763 None,
764 vec![make_component("rocm", "7.2.1")],
765 );
766 assert!(
767 manifest.verify_expiry().is_ok(),
768 "no expiry should be accepted"
769 );
770 }
771
772 struct MockFetcher {
776 remote: Option<Manifest>,
777 cached: Option<Manifest>,
778 }
779
780 impl ManifestFetcher for MockFetcher {
781 fn fetch_remote(&self) -> Option<Manifest> {
782 self.remote.clone()
783 }
784
785 fn load_cached(&self) -> Option<Manifest> {
786 self.cached.clone()
787 }
788 }
789
790 #[test]
791 fn test_manifest_fallback_returns_fresh_remote_when_available() {
792 let remote = create_signed_manifest(
793 CURRENT_SCHEMA_VERSION,
794 10,
795 None,
796 vec![make_component("rocm", "7.3.0")],
797 );
798
799 let fetcher = MockFetcher {
800 remote: Some(remote.clone()),
801 cached: None,
802 };
803
804 let resolved = resolve_manifest(&fetcher);
805 assert_eq!(resolved.source, ManifestSource::FreshRemote);
806 assert_eq!(resolved.manifest.sequence, 10);
807 }
808
809 #[test]
810 fn test_manifest_fallback_returns_cached_when_remote_fails() {
811 let cached = create_signed_manifest(
812 CURRENT_SCHEMA_VERSION,
813 5,
814 None,
815 vec![make_component("rocm", "7.2.1")],
816 );
817
818 let fetcher = MockFetcher {
819 remote: None,
820 cached: Some(cached.clone()),
821 };
822
823 let resolved = resolve_manifest(&fetcher);
824 assert_eq!(resolved.source, ManifestSource::CachedRemote);
825 assert_eq!(resolved.manifest.sequence, 5);
826 }
827
828 #[test]
829 fn test_manifest_fallback_returns_cached_when_remote_trust_fails() {
830 let mut bad_remote = create_signed_manifest(
832 CURRENT_SCHEMA_VERSION,
833 10,
834 None,
835 vec![make_component("rocm", "7.3.0")],
836 );
837 bad_remote.signature = Some("bad".to_string());
838
839 let cached = create_signed_manifest(
840 CURRENT_SCHEMA_VERSION,
841 5,
842 None,
843 vec![make_component("rocm", "7.2.1")],
844 );
845
846 let fetcher = MockFetcher {
847 remote: Some(bad_remote),
848 cached: Some(cached),
849 };
850
851 let resolved = resolve_manifest(&fetcher);
852 assert_eq!(resolved.source, ManifestSource::CachedRemote);
853 }
854
855 #[test]
856 fn test_manifest_fallback_returns_baseline_when_both_fail() {
857 let fetcher = MockFetcher {
858 remote: None,
859 cached: None,
860 };
861
862 let resolved = resolve_manifest(&fetcher);
863 assert_eq!(resolved.source, ManifestSource::Baseline);
864 assert!(
865 !resolved.manifest.components.is_empty(),
866 "baseline must have components"
867 );
868 }
869
870 #[test]
871 fn test_manifest_fallback_baseline_never_errors() {
872 let fetcher = MockFetcher {
874 remote: None,
875 cached: None,
876 };
877
878 let resolved = resolve_manifest(&fetcher);
879 assert_eq!(resolved.source, ManifestSource::Baseline);
880 }
881
882 #[test]
885 fn test_manifest_verify_trust_all_pass() {
886 let manifest = create_signed_manifest(
887 CURRENT_SCHEMA_VERSION,
888 1,
889 None,
890 vec![make_component("rocm", "7.2.1")],
891 );
892 assert!(manifest.verify_trust().is_ok());
893 }
894
895 #[test]
896 fn test_manifest_verify_trust_fails_on_bad_signature() {
897 let mut manifest = create_signed_manifest(
898 CURRENT_SCHEMA_VERSION,
899 1,
900 None,
901 vec![make_component("rocm", "7.2.1")],
902 );
903 manifest.signature = Some("bad".to_string());
904 assert!(manifest.verify_trust().is_err());
905 }
906
907 #[test]
908 fn test_manifest_verify_trust_fails_on_old_schema() {
909 let manifest = create_signed_manifest(0, 1, None, vec![make_component("rocm", "7.2.1")]);
910 assert!(manifest.verify_trust().is_err());
911 }
912
913 #[test]
914 fn test_manifest_verify_trust_fails_on_expired() {
915 let one_hour_ago = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
916 let manifest = create_signed_manifest(
917 CURRENT_SCHEMA_VERSION,
918 1,
919 Some(one_hour_ago),
920 vec![make_component("rocm", "7.2.1")],
921 );
922 assert!(manifest.verify_trust().is_err());
923 }
924
925 #[test]
928 fn test_manifest_serde_roundtrip() {
929 let manifest = create_signed_manifest(
930 CURRENT_SCHEMA_VERSION,
931 42,
932 Some("2027-01-01T00:00:00Z".to_string()),
933 vec![
934 make_component("rocm", "7.2.1"),
935 ManifestComponent {
936 id: "pytorch".to_string(),
937 version: "2.6.0".to_string(),
938 script: "scripts/install_pytorch_rocm.sh".to_string(),
939 category: Category::Core,
940 validation_tier: ValidationTier::Validated,
941 min_rocm_version: String::new(),
942 compatible_channels: vec![],
943 dependencies: vec![],
944 },
945 ],
946 );
947
948 let json = manifest.to_json().unwrap();
949 let back: Manifest = Manifest::from_json(&json).unwrap();
950 assert_eq!(manifest, back);
951 }
952
953 #[test]
954 fn test_manifest_overlay_serde_roundtrip() {
955 let overlay = ManifestOverlay {
956 components: vec![
957 make_component("rocm", "7.3.0"),
958 ManifestComponent {
959 id: "new-comp".to_string(),
960 version: "1.0.0".to_string(),
961 script: "scripts/install_new.sh".to_string(),
962 category: Category::Extension,
963 validation_tier: ValidationTier::Candidate,
964 min_rocm_version: String::new(),
965 compatible_channels: vec![],
966 dependencies: vec![],
967 },
968 ],
969 };
970
971 let json = serde_json::to_string(&overlay).unwrap();
972 let back: ManifestOverlay = serde_json::from_str(&json).unwrap();
973 assert_eq!(overlay, back);
974 }
975
976 #[test]
977 fn test_manifest_error_display() {
978 let err = ManifestTrustError::SignatureVerificationError("test".to_string());
979 assert!(err.to_string().contains("test"));
980
981 let err = ManifestTrustError::SchemaVersionError {
982 version: 0,
983 minimum: 1,
984 };
985 assert!(err.to_string().contains("0"));
986 assert!(err.to_string().contains("1"));
987
988 let err = ManifestTrustError::ManifestExpiredError {
989 expires_at: "2026-01-01T00:00:00Z".to_string(),
990 reason: "expired".to_string(),
991 };
992 assert!(err.to_string().contains("2026-01-01"));
993 }
994}