Skip to main content

rusty_stack/core/
manifest.rs

1//! Manifest schema, trust model, and fallback chain.
2//!
3//! This module implements:
4//! - [`Manifest`] — the top-level manifest structure with components and metadata
5//! - [`ManifestOverlay`] — a remote overlay that merges onto the baseline
6//! - Trust verification — signature, schema version anti-rollback, expiry
7//! - Three-tier fallback chain — fresh remote → cached remote → bundled baseline
8//!
9//! # Trust Model
10//!
11//! Every remote manifest must pass three trust checks before use:
12//! 1. **Signature verification** — HMAC-SHA256 against a known key
13//! 2. **Schema version** — must be ≥ `MIN_SCHEMA_VERSION` (anti-rollback)
14//! 3. **Expiry** — if `expires_at` is set, must not be in the past
15//!
16//! # Fallback Chain
17//!
18//! ```text
19//! fresh remote (if trust checks pass)
20//!   ↓ fetch failure
21//! cached remote (if valid cache exists)
22//!   ↓ no cache
23//! bundled baseline (always available, compiled into binary)
24//! ```
25
26use crate::core::types::{Category, ValidationTier};
27use chrono::{DateTime, Utc};
28use serde::{Deserialize, Serialize};
29use sha2::{Digest, Sha256};
30
31/// Current schema version produced/consumed by this binary.
32pub const CURRENT_SCHEMA_VERSION: u32 = 2;
33
34/// Minimum supported schema version. Manifests with a lower version are rejected.
35pub const MIN_SCHEMA_VERSION: u32 = 1;
36
37/// HMAC key used for manifest signature verification.
38/// In production this would be a proper public-key system.
39const MANIFEST_SIGNING_KEY: &[u8] = b"rusty-stack-manifest-signing-key-v1";
40
41// ---------------------------------------------------------------------------
42// Manifest component entry
43// ---------------------------------------------------------------------------
44
45/// A single component entry within a manifest.
46#[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    /// Minimum ROCm version required for this component (empty = any).
55    #[serde(default)]
56    pub min_rocm_version: String,
57    /// ROCm channels this component is compatible with (empty = all).
58    #[serde(default)]
59    pub compatible_channels: Vec<String>,
60    /// Component dependencies (empty = none).
61    #[serde(default)]
62    pub dependencies: Vec<String>,
63}
64
65fn default_validation_tier() -> ValidationTier {
66    ValidationTier::Candidate
67}
68
69// ---------------------------------------------------------------------------
70// Manifest
71// ---------------------------------------------------------------------------
72
73/// The top-level manifest structure.
74///
75/// Contains metadata (schema version, sequence, timestamps) and a list of
76/// component entries.
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
78pub struct Manifest {
79    pub schema_version: u32,
80    /// Monotonically increasing sequence number for anti-rollback.
81    #[serde(default)]
82    pub sequence: u64,
83    /// ISO 8601 timestamp of when the manifest was generated.
84    #[serde(default)]
85    pub generated_at: String,
86    /// ISO 8601 timestamp after which the manifest is considered stale.
87    /// `None` means the manifest never expires.
88    #[serde(default)]
89    pub expires_at: Option<String>,
90    /// Minimum runtime version required to process this manifest.
91    #[serde(default)]
92    pub min_runtime_version: String,
93    /// The component entries.
94    pub components: Vec<ManifestComponent>,
95    /// HMAC-SHA256 signature over the canonical JSON body (excludes this field).
96    #[serde(default)]
97    pub signature: Option<String>,
98}
99
100impl Manifest {
101    /// Parse a manifest from a JSON string.
102    pub fn from_json(json: &str) -> anyhow::Result<Self> {
103        let manifest: Manifest = serde_json::from_str(json)?;
104        Ok(manifest)
105    }
106
107    /// Serialize the manifest to a canonical JSON string.
108    pub fn to_json(&self) -> anyhow::Result<String> {
109        let json = serde_json::to_string(self)?;
110        Ok(json)
111    }
112
113    /// Load the bundled baseline manifest compiled into the binary.
114    pub fn load_baseline() -> anyhow::Result<Self> {
115        let json = include_str!("fixtures/baseline_manifest.json");
116        Self::from_json(json)
117    }
118
119    /// Compute the HMAC-SHA256 signature over the manifest body.
120    ///
121    /// The signature covers all fields *except* the `signature` field itself.
122    /// We produce a canonical JSON representation without the signature, then
123    /// compute HMAC-SHA256.
124    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    /// Verify the manifest's signature.
137    ///
138    /// Returns `Ok(())` if the signature is present and valid.
139    /// Returns `Err(ManifestTrustError::SignatureVerificationError)` otherwise.
140    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    /// Check the schema version against the minimum supported version.
159    ///
160    /// Returns `Ok(())` if `schema_version >= MIN_SCHEMA_VERSION`.
161    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    /// Check the manifest expiry.
173    ///
174    /// Returns `Ok(())` if the manifest has no expiry or has not expired.
175    /// Returns `Err(ManifestTrustError::ManifestExpiredError)` if expired.
176    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    /// Run all trust checks: signature, schema version, and expiry.
200    ///
201    /// Returns the first error encountered, or `Ok(())` if all pass.
202    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    /// Produce the canonical JSON of the manifest body without the signature field.
210    fn signatureless_json(&self) -> String {
211        // Create a copy without the signature
212        let mut copy = self.clone();
213        copy.signature = None;
214        serde_json::to_string(&copy).unwrap_or_default()
215    }
216}
217
218// ---------------------------------------------------------------------------
219// Manifest Overlay
220// ---------------------------------------------------------------------------
221
222/// A remote overlay that merges onto a baseline manifest.
223///
224/// The overlay contains a subset of components. For any component `id` present
225/// in both baseline and overlay, the overlay's fields replace the baseline's.
226/// For any component `id` present only in the overlay, it is appended.
227/// Baseline-only entries remain unchanged.
228///
229/// The merge is deterministic: the result depends only on the content of
230/// baseline and overlay, not on ordering.
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
232pub struct ManifestOverlay {
233    /// Overlay components to merge.
234    pub components: Vec<ManifestComponent>,
235}
236
237impl ManifestOverlay {
238    /// Parse an overlay from a JSON string.
239    pub fn from_json(json: &str) -> anyhow::Result<Self> {
240        let overlay: ManifestOverlay = serde_json::from_str(json)?;
241        Ok(overlay)
242    }
243
244    /// Merge this overlay onto a baseline manifest.
245    ///
246    /// Returns a new [`Manifest`] with the merged component list.
247    /// The baseline's metadata (schema_version, sequence, etc.) is preserved.
248    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                // Replace fields of the existing component with overlay values
258                *existing = overlay_comp.clone();
259            } else {
260                // Append new component
261                merged.components.push(overlay_comp.clone());
262            }
263        }
264
265        merged
266    }
267}
268
269// ---------------------------------------------------------------------------
270// Trust Errors
271// ---------------------------------------------------------------------------
272
273/// Errors that can occur during manifest trust verification.
274#[derive(Debug, Clone, PartialEq)]
275pub enum ManifestTrustError {
276    /// The manifest signature is missing or does not match.
277    SignatureVerificationError(String),
278    /// The schema version is below the minimum supported version.
279    SchemaVersionError { version: u32, minimum: u32 },
280    /// The manifest has expired.
281    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// ---------------------------------------------------------------------------
306// Fallback Chain
307// ---------------------------------------------------------------------------
308
309/// Source of the manifest after fallback resolution.
310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub enum ManifestSource {
312    /// Freshly fetched from remote URL.
313    FreshRemote,
314    /// Loaded from local cache (remote was unreachable).
315    CachedRemote,
316    /// Bundled with the binary (last resort).
317    Baseline,
318}
319
320/// Result of manifest fallback resolution.
321#[derive(Debug, Clone)]
322pub struct ResolvedManifest {
323    pub manifest: Manifest,
324    pub source: ManifestSource,
325}
326
327/// Trait for fetching and caching manifests. Implementations provide the
328/// actual remote fetch and cache read/write logic.
329pub trait ManifestFetcher {
330    /// Attempt to fetch a fresh remote manifest.
331    /// Returns `None` if the fetch fails for any reason.
332    fn fetch_remote(&self) -> Option<Manifest>;
333
334    /// Attempt to load a cached remote manifest.
335    /// Returns `None` if no valid cache exists.
336    fn load_cached(&self) -> Option<Manifest>;
337}
338
339/// Resolve a manifest using the three-tier fallback chain.
340///
341/// 1. Try to fetch a fresh remote manifest and verify trust.
342/// 2. If that fails, try the cached remote manifest.
343/// 3. If that also fails, fall back to the bundled baseline.
344///
345/// The baseline is always available (compiled into the binary), so this
346/// function never returns an error.
347pub fn resolve_manifest(fetcher: &dyn ManifestFetcher) -> ResolvedManifest {
348    // Tier 1: Fresh remote
349    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    // Tier 2: Cached remote (must also pass trust checks)
359    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    // Tier 3: Bundled baseline (always succeeds)
369    let baseline = Manifest::load_baseline().expect("bundled baseline must be valid");
370    ResolvedManifest {
371        manifest: baseline,
372        source: ManifestSource::Baseline,
373    }
374}
375
376// ---------------------------------------------------------------------------
377// Helper: create a signed manifest for testing
378// ---------------------------------------------------------------------------
379
380/// Create a test manifest with a valid signature.
381pub 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// ===========================================================================
401// Tests
402// ===========================================================================
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    /// Helper to create a simple manifest component for testing.
409    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    // ---- VAL-CORE-005: Baseline loading ----
423
424    #[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            // Category should be a valid variant (serde already ensured this)
448            assert!(
449                Category::from_label(&comp.category.to_string()).is_some(),
450                "component '{}' has invalid category",
451                comp.id
452            );
453            // Verify dependencies field exists and is a Vec<String>
454            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    // ---- VAL-CORE-006: Remote overlay merge ----
475
476    #[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                // Overlapping: update pytorch version
495                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                // New component
506                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        // Should have 4 components: rocm, pytorch (updated), triton, vllm (new)
522        assert_eq!(merged.components.len(), 4);
523
524        // rocm unchanged
525        let rocm = merged.components.iter().find(|c| c.id == "rocm").unwrap();
526        assert_eq!(rocm.version, "7.2.1");
527
528        // pytorch updated
529        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        // triton unchanged
537        let triton = merged.components.iter().find(|c| c.id == "triton").unwrap();
538        assert_eq!(triton.version, "3.2.0");
539
540        // vllm appended
541        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    // ---- VAL-CORE-007: Signature verification ----
589
590    #[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        // Tamper with the manifest content
614        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    // ---- VAL-CORE-008: Schema version anti-rollback ----
661
662    #[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    // ---- VAL-CORE-009: Expiry rejection ----
720
721    #[test]
722    fn test_manifest_expiry_rejects_expired_manifest() {
723        // Set expiry to 1 hour ago
724        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        // Set expiry to 1 year from now
745        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    // ---- VAL-CORE-010: Fallback chain ----
773
774    /// A mock fetcher for testing the fallback chain.
775    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        // Remote with bad signature
831        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        // Even with no remote and no cache, baseline should always work
873        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    // ---- Combined trust verification ----
883
884    #[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    // ---- Serde roundtrip ----
926
927    #[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}