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    /// Mutual-exclusion group (empty = none). Components sharing the same
64    /// non-empty `exclusive_group` install conflicting/overlapping artifacts
65    /// (e.g. Flash Attention's Triton vs CK backends both install the identical
66    /// `flash_attn` package, so only the last-installed remains active). The
67    /// planner keeps AT MOST ONE component per group — the first listed in the
68    /// manifest (the recommended default) unless a later one is an explicit
69    /// update target — so a blanket `--all-safe` can never schedule redundant,
70    /// order-dependent builds of mutually exclusive backends.
71    #[serde(default)]
72    pub exclusive_group: String,
73}
74
75fn default_validation_tier() -> ValidationTier {
76    ValidationTier::Candidate
77}
78
79// ---------------------------------------------------------------------------
80// Manifest
81// ---------------------------------------------------------------------------
82
83/// The top-level manifest structure.
84///
85/// Contains metadata (schema version, sequence, timestamps) and a list of
86/// component entries.
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
88pub struct Manifest {
89    pub schema_version: u32,
90    /// Monotonically increasing sequence number for anti-rollback.
91    #[serde(default)]
92    pub sequence: u64,
93    /// ISO 8601 timestamp of when the manifest was generated.
94    #[serde(default)]
95    pub generated_at: String,
96    /// ISO 8601 timestamp after which the manifest is considered stale.
97    /// `None` means the manifest never expires.
98    #[serde(default)]
99    pub expires_at: Option<String>,
100    /// Minimum runtime version required to process this manifest.
101    #[serde(default)]
102    pub min_runtime_version: String,
103    /// The component entries.
104    pub components: Vec<ManifestComponent>,
105    /// HMAC-SHA256 signature over the canonical JSON body (excludes this field).
106    #[serde(default)]
107    pub signature: Option<String>,
108}
109
110impl Manifest {
111    /// Parse a manifest from a JSON string.
112    pub fn from_json(json: &str) -> anyhow::Result<Self> {
113        let manifest: Manifest = serde_json::from_str(json)?;
114        Ok(manifest)
115    }
116
117    /// Serialize the manifest to a canonical JSON string.
118    pub fn to_json(&self) -> anyhow::Result<String> {
119        let json = serde_json::to_string(self)?;
120        Ok(json)
121    }
122
123    /// Load the bundled baseline manifest compiled into the binary.
124    pub fn load_baseline() -> anyhow::Result<Self> {
125        let json = include_str!("fixtures/baseline_manifest.json");
126        Self::from_json(json)
127    }
128
129    /// Compute the HMAC-SHA256 signature over the manifest body.
130    ///
131    /// The signature covers all fields *except* the `signature` field itself.
132    /// We produce a canonical JSON representation without the signature, then
133    /// compute HMAC-SHA256.
134    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    /// Verify the manifest's signature.
147    ///
148    /// Returns `Ok(())` if the signature is present and valid.
149    /// Returns `Err(ManifestTrustError::SignatureVerificationError)` otherwise.
150    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    /// Check the schema version against the minimum supported version.
169    ///
170    /// Returns `Ok(())` if `schema_version >= MIN_SCHEMA_VERSION`.
171    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    /// Check the manifest expiry.
183    ///
184    /// Returns `Ok(())` if the manifest has no expiry or has not expired.
185    /// Returns `Err(ManifestTrustError::ManifestExpiredError)` if expired.
186    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    /// Run all trust checks: signature, schema version, and expiry.
210    ///
211    /// Returns the first error encountered, or `Ok(())` if all pass.
212    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    /// Produce the canonical JSON of the manifest body without the signature field.
220    fn signatureless_json(&self) -> String {
221        // Create a copy without the signature
222        let mut copy = self.clone();
223        copy.signature = None;
224        serde_json::to_string(&copy).unwrap_or_default()
225    }
226}
227
228// ---------------------------------------------------------------------------
229// Manifest Overlay
230// ---------------------------------------------------------------------------
231
232/// A remote overlay that merges onto a baseline manifest.
233///
234/// The overlay contains a subset of components. For any component `id` present
235/// in both baseline and overlay, the overlay's fields replace the baseline's.
236/// For any component `id` present only in the overlay, it is appended.
237/// Baseline-only entries remain unchanged.
238///
239/// The merge is deterministic: the result depends only on the content of
240/// baseline and overlay, not on ordering.
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
242pub struct ManifestOverlay {
243    /// Overlay components to merge.
244    pub components: Vec<ManifestComponent>,
245}
246
247impl ManifestOverlay {
248    /// Parse an overlay from a JSON string.
249    pub fn from_json(json: &str) -> anyhow::Result<Self> {
250        let overlay: ManifestOverlay = serde_json::from_str(json)?;
251        Ok(overlay)
252    }
253
254    /// Merge this overlay onto a baseline manifest.
255    ///
256    /// Returns a new [`Manifest`] with the merged component list.
257    /// The baseline's metadata (schema_version, sequence, etc.) is preserved.
258    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                // Replace fields of the existing component with overlay values
268                *existing = overlay_comp.clone();
269            } else {
270                // Append new component
271                merged.components.push(overlay_comp.clone());
272            }
273        }
274
275        merged
276    }
277}
278
279// ---------------------------------------------------------------------------
280// Trust Errors
281// ---------------------------------------------------------------------------
282
283/// Errors that can occur during manifest trust verification.
284#[derive(Debug, Clone, PartialEq)]
285pub enum ManifestTrustError {
286    /// The manifest signature is missing or does not match.
287    SignatureVerificationError(String),
288    /// The schema version is below the minimum supported version.
289    SchemaVersionError { version: u32, minimum: u32 },
290    /// The manifest has expired.
291    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// ---------------------------------------------------------------------------
316// Fallback Chain
317// ---------------------------------------------------------------------------
318
319/// Source of the manifest after fallback resolution.
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321pub enum ManifestSource {
322    /// Freshly fetched from remote URL.
323    FreshRemote,
324    /// Loaded from local cache (remote was unreachable).
325    CachedRemote,
326    /// Bundled with the binary (last resort).
327    Baseline,
328}
329
330/// Result of manifest fallback resolution.
331#[derive(Debug, Clone)]
332pub struct ResolvedManifest {
333    pub manifest: Manifest,
334    pub source: ManifestSource,
335}
336
337/// Trait for fetching and caching manifests. Implementations provide the
338/// actual remote fetch and cache read/write logic.
339pub trait ManifestFetcher {
340    /// Attempt to fetch a fresh remote manifest.
341    /// Returns `None` if the fetch fails for any reason.
342    fn fetch_remote(&self) -> Option<Manifest>;
343
344    /// Attempt to load a cached remote manifest.
345    /// Returns `None` if no valid cache exists.
346    fn load_cached(&self) -> Option<Manifest>;
347}
348
349/// Resolve a manifest using the three-tier fallback chain.
350///
351/// 1. Try to fetch a fresh remote manifest and verify trust.
352/// 2. If that fails, try the cached remote manifest.
353/// 3. If that also fails, fall back to the bundled baseline.
354///
355/// The baseline is always available (compiled into the binary), so this
356/// function never returns an error.
357pub fn resolve_manifest(fetcher: &dyn ManifestFetcher) -> ResolvedManifest {
358    // Tier 1: Fresh remote
359    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    // Tier 2: Cached remote (must also pass trust checks)
369    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    // Tier 3: Bundled baseline (always succeeds)
379    let baseline = Manifest::load_baseline().expect("bundled baseline must be valid");
380    ResolvedManifest {
381        manifest: baseline,
382        source: ManifestSource::Baseline,
383    }
384}
385
386// ---------------------------------------------------------------------------
387// Helper: create a signed manifest for testing
388// ---------------------------------------------------------------------------
389
390/// Create a test manifest with a valid signature.
391pub 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// ===========================================================================
411// Tests
412// ===========================================================================
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    /// Helper to create a simple manifest component for testing.
419    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    // ---- VAL-CORE-005: Baseline loading ----
434
435    #[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            // Category should be a valid variant (serde already ensured this)
459            assert!(
460                Category::from_label(&comp.category.to_string()).is_some(),
461                "component '{}' has invalid category",
462                comp.id
463            );
464            // Verify dependencies field exists and is a Vec<String>
465            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    // ---- VAL-CORE-006: Remote overlay merge ----
486
487    #[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                // Overlapping: update pytorch version
506                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                // New component
518                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        // Should have 4 components: rocm, pytorch (updated), triton, vllm (new)
535        assert_eq!(merged.components.len(), 4);
536
537        // rocm unchanged
538        let rocm = merged.components.iter().find(|c| c.id == "rocm").unwrap();
539        assert_eq!(rocm.version, "7.2.1");
540
541        // pytorch updated
542        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        // triton unchanged
550        let triton = merged.components.iter().find(|c| c.id == "triton").unwrap();
551        assert_eq!(triton.version, "3.2.0");
552
553        // vllm appended
554        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    // ---- VAL-CORE-007: Signature verification ----
602
603    #[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        // Tamper with the manifest content
627        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    // ---- VAL-CORE-008: Schema version anti-rollback ----
674
675    #[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    // ---- VAL-CORE-009: Expiry rejection ----
733
734    #[test]
735    fn test_manifest_expiry_rejects_expired_manifest() {
736        // Set expiry to 1 hour ago
737        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        // Set expiry to 1 year from now
758        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    // ---- VAL-CORE-010: Fallback chain ----
786
787    /// A mock fetcher for testing the fallback chain.
788    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        // Remote with bad signature
844        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        // Even with no remote and no cache, baseline should always work
886        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    // ---- Combined trust verification ----
896
897    #[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    // ---- Serde roundtrip ----
939
940    #[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}