Skip to main content

canic_backup/manifest/
mod.rs

1use candid::Principal;
2use serde::{Deserialize, Serialize};
3use serde_json::json;
4use std::{
5    collections::{BTreeMap, BTreeSet},
6    str::FromStr,
7};
8use thiserror::Error as ThisError;
9
10const SUPPORTED_MANIFEST_VERSION: u16 = 1;
11const SHA256_ALGORITHM: &str = "sha256";
12const DESIGN_V1: &str = "design";
13const TOPOLOGY_HASH_INPUT_V1: &str = "sorted(pid,parent_pid,role,module_hash)";
14
15///
16/// FleetBackupManifest
17///
18
19#[derive(Clone, Debug, Deserialize, Serialize)]
20pub struct FleetBackupManifest {
21    pub manifest_version: u16,
22    pub backup_id: String,
23    pub created_at: String,
24    pub tool: ToolMetadata,
25    pub source: SourceMetadata,
26    pub consistency: ConsistencySection,
27    pub fleet: FleetSection,
28    pub verification: VerificationPlan,
29}
30
31impl FleetBackupManifest {
32    /// Validate the manifest-level contract before backup finalization or restore planning.
33    pub fn validate(&self) -> Result<(), ManifestValidationError> {
34        validate_manifest_version(self.manifest_version)?;
35        validate_nonempty("backup_id", &self.backup_id)?;
36        validate_nonempty("created_at", &self.created_at)?;
37        self.tool.validate()?;
38        self.source.validate()?;
39        self.consistency.validate()?;
40        self.fleet.validate()?;
41        self.verification.validate()?;
42        validate_consistency_against_fleet(&self.consistency, &self.fleet)?;
43        validate_verification_against_fleet(&self.verification, &self.fleet)?;
44        Ok(())
45    }
46
47    /// Build a design-conformance report for operator preflight checks.
48    #[must_use]
49    pub fn design_conformance_report(&self) -> ManifestDesignConformanceReport {
50        ManifestDesignConformanceReport::from_manifest(self)
51    }
52}
53
54///
55/// ManifestDesignConformanceReport
56///
57
58#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
59pub struct ManifestDesignConformanceReport {
60    pub design_version: String,
61    pub design_v1_ready: bool,
62    pub topology: TopologyConformance,
63    pub backup_units: BackupUnitConformance,
64    pub quiescence: QuiescenceConformance,
65    pub verification: VerificationConformance,
66    pub identity: IdentityConformance,
67    pub snapshot_provenance: SnapshotProvenanceConformance,
68    pub restore_order: RestoreOrderConformance,
69}
70
71impl ManifestDesignConformanceReport {
72    /// Build one report from an already-loaded manifest.
73    fn from_manifest(manifest: &FleetBackupManifest) -> Self {
74        let topology = TopologyConformance::from_manifest(manifest);
75        let backup_units = BackupUnitConformance::from_manifest(manifest);
76        let quiescence = QuiescenceConformance::from_manifest(manifest);
77        let verification = VerificationConformance::from_manifest(manifest);
78        let identity = IdentityConformance::from_manifest(manifest);
79        let snapshot_provenance = SnapshotProvenanceConformance::from_manifest(manifest);
80        let restore_order = RestoreOrderConformance::from_manifest(manifest);
81        let design_v1_ready = topology.design_v1_ready
82            && backup_units.design_v1_ready
83            && quiescence.design_v1_ready
84            && verification.design_v1_ready
85            && snapshot_provenance.design_v1_ready
86            && restore_order.design_v1_ready;
87
88        Self {
89            design_version: DESIGN_V1.to_string(),
90            design_v1_ready,
91            topology,
92            backup_units,
93            quiescence,
94            verification,
95            identity,
96            snapshot_provenance,
97            restore_order,
98        }
99    }
100}
101
102/// Build the manifest validation summary emitted by CLI and preflight workflows.
103#[must_use]
104pub fn manifest_validation_summary(manifest: &FleetBackupManifest) -> serde_json::Value {
105    let design_conformance = manifest.design_conformance_report();
106
107    json!({
108        "status": "valid",
109        "backup_id": manifest.backup_id,
110        "members": manifest.fleet.members.len(),
111        "backup_unit_count": manifest.consistency.backup_units.len(),
112        "consistency_mode": consistency_mode_name(&manifest.consistency.mode),
113        "topology_hash": manifest.fleet.topology_hash,
114        "topology_hash_algorithm": manifest.fleet.topology_hash_algorithm,
115        "topology_hash_input": manifest.fleet.topology_hash_input,
116        "topology_validation_status": "validated",
117        "design_conformance": design_conformance,
118        "backup_unit_kinds": backup_unit_kind_counts(manifest),
119        "backup_units": manifest
120            .consistency
121            .backup_units
122            .iter()
123            .map(|unit| json!({
124                "unit_id": unit.unit_id,
125                "kind": backup_unit_kind_name(&unit.kind),
126                "role_count": unit.roles.len(),
127                "dependency_count": unit.dependency_closure.len(),
128                "topology_validation": unit.topology_validation,
129            }))
130            .collect::<Vec<_>>(),
131    })
132}
133
134// Count backup units by stable serialized kind name.
135fn backup_unit_kind_counts(manifest: &FleetBackupManifest) -> serde_json::Value {
136    let mut whole_fleet = 0;
137    let mut control_plane_subset = 0;
138    let mut subtree_rooted = 0;
139    let mut flat = 0;
140    for unit in &manifest.consistency.backup_units {
141        match &unit.kind {
142            BackupUnitKind::WholeFleet => whole_fleet += 1,
143            BackupUnitKind::ControlPlaneSubset => control_plane_subset += 1,
144            BackupUnitKind::SubtreeRooted => subtree_rooted += 1,
145            BackupUnitKind::Flat => flat += 1,
146        }
147    }
148
149    json!({
150        "whole_fleet": whole_fleet,
151        "control_plane_subset": control_plane_subset,
152        "subtree_rooted": subtree_rooted,
153        "flat": flat,
154    })
155}
156
157// Return the stable serialized name for a consistency mode.
158pub(crate) const fn consistency_mode_name(mode: &ConsistencyMode) -> &'static str {
159    match mode {
160        ConsistencyMode::CrashConsistent => "crash-consistent",
161        ConsistencyMode::QuiescedUnit => "quiesced-unit",
162    }
163}
164
165// Return the stable serialized name for a backup unit kind.
166pub(crate) const fn backup_unit_kind_name(kind: &BackupUnitKind) -> &'static str {
167    match kind {
168        BackupUnitKind::WholeFleet => "whole-fleet",
169        BackupUnitKind::ControlPlaneSubset => "control-plane-subset",
170        BackupUnitKind::SubtreeRooted => "subtree-rooted",
171        BackupUnitKind::Flat => "flat",
172    }
173}
174
175///
176/// TopologyConformance
177///
178
179#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
180#[allow(clippy::struct_excessive_bools)]
181pub struct TopologyConformance {
182    pub design_v1_ready: bool,
183    pub algorithm_sha256: bool,
184    pub canonical_input: bool,
185    pub discovery_matches_pre_snapshot: bool,
186    pub accepted_matches_discovery: bool,
187}
188
189impl TopologyConformance {
190    /// Summarize topology hash stability and canonical input metadata.
191    fn from_manifest(manifest: &FleetBackupManifest) -> Self {
192        let algorithm_sha256 = manifest.fleet.topology_hash_algorithm == SHA256_ALGORITHM;
193        let canonical_input = manifest.fleet.topology_hash_input == TOPOLOGY_HASH_INPUT_V1;
194        let discovery_matches_pre_snapshot =
195            manifest.fleet.discovery_topology_hash == manifest.fleet.pre_snapshot_topology_hash;
196        let accepted_matches_discovery =
197            manifest.fleet.topology_hash == manifest.fleet.discovery_topology_hash;
198        let design_v1_ready = algorithm_sha256
199            && canonical_input
200            && discovery_matches_pre_snapshot
201            && accepted_matches_discovery;
202
203        Self {
204            design_v1_ready,
205            algorithm_sha256,
206            canonical_input,
207            discovery_matches_pre_snapshot,
208            accepted_matches_discovery,
209        }
210    }
211}
212
213///
214/// BackupUnitConformance
215///
216
217#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
218#[allow(clippy::struct_excessive_bools)]
219pub struct BackupUnitConformance {
220    pub design_v1_ready: bool,
221    pub unit_count: usize,
222    pub all_units_have_roles: bool,
223    pub all_units_have_topology_validation: bool,
224    pub all_roles_covered: bool,
225    pub flat_units: usize,
226    pub flat_units_with_reason: usize,
227    pub subtree_units: usize,
228    pub subtree_units_declared_closed: usize,
229}
230
231impl BackupUnitConformance {
232    /// Summarize backup-unit boundary metadata required by the design.
233    fn from_manifest(manifest: &FleetBackupManifest) -> Self {
234        let unit_count = manifest.consistency.backup_units.len();
235        let all_units_have_roles = manifest
236            .consistency
237            .backup_units
238            .iter()
239            .all(|unit| !unit.roles.is_empty());
240        let all_units_have_topology_validation = manifest
241            .consistency
242            .backup_units
243            .iter()
244            .all(|unit| !unit.topology_validation.trim().is_empty());
245        let all_roles_covered = all_fleet_roles_covered(manifest);
246        let flat_units = manifest
247            .consistency
248            .backup_units
249            .iter()
250            .filter(|unit| matches!(unit.kind, BackupUnitKind::Flat))
251            .count();
252        let flat_units_with_reason = manifest
253            .consistency
254            .backup_units
255            .iter()
256            .filter(|unit| matches!(unit.kind, BackupUnitKind::Flat))
257            .filter(|unit| {
258                unit.consistency_reason
259                    .as_deref()
260                    .is_some_and(|reason| !reason.trim().is_empty())
261            })
262            .count();
263        let subtree_units = manifest
264            .consistency
265            .backup_units
266            .iter()
267            .filter(|unit| matches!(unit.kind, BackupUnitKind::SubtreeRooted))
268            .count();
269        let subtree_units_declared_closed = manifest
270            .consistency
271            .backup_units
272            .iter()
273            .filter(|unit| matches!(unit.kind, BackupUnitKind::SubtreeRooted))
274            .filter(|unit| unit.topology_validation == "subtree-closed")
275            .count();
276        let design_v1_ready = unit_count > 0
277            && all_units_have_roles
278            && all_units_have_topology_validation
279            && all_roles_covered
280            && flat_units == flat_units_with_reason
281            && subtree_units == subtree_units_declared_closed;
282
283        Self {
284            design_v1_ready,
285            unit_count,
286            all_units_have_roles,
287            all_units_have_topology_validation,
288            all_roles_covered,
289            flat_units,
290            flat_units_with_reason,
291            subtree_units,
292            subtree_units_declared_closed,
293        }
294    }
295}
296
297///
298/// QuiescenceConformance
299///
300
301#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
302#[allow(clippy::struct_excessive_bools)]
303pub struct QuiescenceConformance {
304    pub design_v1_ready: bool,
305    pub mode: ConsistencyMode,
306    pub quiescence_required: bool,
307    pub unit_count: usize,
308    pub units_with_strategy: usize,
309    pub all_required_units_have_strategy: bool,
310}
311
312impl QuiescenceConformance {
313    /// Summarize the explicit quiescence boundary for the backup.
314    fn from_manifest(manifest: &FleetBackupManifest) -> Self {
315        let quiescence_required =
316            matches!(manifest.consistency.mode, ConsistencyMode::QuiescedUnit);
317        let unit_count = manifest.consistency.backup_units.len();
318        let units_with_strategy = manifest
319            .consistency
320            .backup_units
321            .iter()
322            .filter(|unit| {
323                unit.quiescence_strategy
324                    .as_deref()
325                    .is_some_and(|strategy| !strategy.trim().is_empty())
326            })
327            .count();
328        let all_required_units_have_strategy =
329            !quiescence_required || units_with_strategy == unit_count;
330        let design_v1_ready = all_required_units_have_strategy;
331
332        Self {
333            design_v1_ready,
334            mode: manifest.consistency.mode.clone(),
335            quiescence_required,
336            unit_count,
337            units_with_strategy,
338            all_required_units_have_strategy,
339        }
340    }
341}
342
343///
344/// VerificationConformance
345///
346
347#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
348pub struct VerificationConformance {
349    pub design_v1_ready: bool,
350    pub member_count: usize,
351    pub members_with_checks: usize,
352    pub all_members_have_checks: bool,
353    pub fleet_check_count: usize,
354    pub role_check_group_count: usize,
355}
356
357impl VerificationConformance {
358    /// Summarize concrete verification coverage for restored members.
359    fn from_manifest(manifest: &FleetBackupManifest) -> Self {
360        let member_count = manifest.fleet.members.len();
361        let members_with_checks = manifest
362            .fleet
363            .members
364            .iter()
365            .filter(|member| !member.verification_checks.is_empty())
366            .count();
367        let all_members_have_checks = member_count == members_with_checks;
368        let fleet_check_count = manifest.verification.fleet_checks.len();
369        let role_check_group_count = manifest.verification.member_checks.len();
370        let design_v1_ready = member_count > 0 && all_members_have_checks;
371
372        Self {
373            design_v1_ready,
374            member_count,
375            members_with_checks,
376            all_members_have_checks,
377            fleet_check_count,
378            role_check_group_count,
379        }
380    }
381}
382
383///
384/// IdentityConformance
385///
386
387#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
388pub struct IdentityConformance {
389    pub fixed_members: usize,
390    pub relocatable_members: usize,
391}
392
393impl IdentityConformance {
394    /// Summarize restore identity modes carried by fleet members.
395    fn from_manifest(manifest: &FleetBackupManifest) -> Self {
396        let fixed_members = manifest
397            .fleet
398            .members
399            .iter()
400            .filter(|member| matches!(member.identity_mode, IdentityMode::Fixed))
401            .count();
402        let relocatable_members = manifest
403            .fleet
404            .members
405            .iter()
406            .filter(|member| matches!(member.identity_mode, IdentityMode::Relocatable))
407            .count();
408
409        Self {
410            fixed_members,
411            relocatable_members,
412        }
413    }
414}
415
416///
417/// SnapshotProvenanceConformance
418///
419
420#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
421#[allow(clippy::struct_excessive_bools)]
422pub struct SnapshotProvenanceConformance {
423    pub design_v1_ready: bool,
424    pub member_count: usize,
425    pub members_with_snapshot_id: usize,
426    pub members_with_checksum: usize,
427    pub members_with_module_hash: usize,
428    pub members_with_wasm_hash: usize,
429    pub members_with_code_version: usize,
430    pub all_members_have_snapshot_id: bool,
431    pub all_members_have_checksum: bool,
432}
433
434impl SnapshotProvenanceConformance {
435    /// Summarize snapshot artifact provenance completeness.
436    fn from_manifest(manifest: &FleetBackupManifest) -> Self {
437        let member_count = manifest.fleet.members.len();
438        let members_with_snapshot_id = manifest
439            .fleet
440            .members
441            .iter()
442            .filter(|member| !member.source_snapshot.snapshot_id.trim().is_empty())
443            .count();
444        let members_with_checksum = manifest
445            .fleet
446            .members
447            .iter()
448            .filter(|member| member.source_snapshot.checksum.is_some())
449            .count();
450        let members_with_module_hash = manifest
451            .fleet
452            .members
453            .iter()
454            .filter(|member| member.source_snapshot.module_hash.is_some())
455            .count();
456        let members_with_wasm_hash = manifest
457            .fleet
458            .members
459            .iter()
460            .filter(|member| member.source_snapshot.wasm_hash.is_some())
461            .count();
462        let members_with_code_version = manifest
463            .fleet
464            .members
465            .iter()
466            .filter(|member| member.source_snapshot.code_version.is_some())
467            .count();
468        let all_members_have_snapshot_id = member_count == members_with_snapshot_id;
469        let all_members_have_checksum = member_count == members_with_checksum;
470        let design_v1_ready =
471            member_count > 0 && all_members_have_snapshot_id && all_members_have_checksum;
472
473        Self {
474            design_v1_ready,
475            member_count,
476            members_with_snapshot_id,
477            members_with_checksum,
478            members_with_module_hash,
479            members_with_wasm_hash,
480            members_with_code_version,
481            all_members_have_snapshot_id,
482            all_members_have_checksum,
483        }
484    }
485}
486
487///
488/// RestoreOrderConformance
489///
490
491#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
492pub struct RestoreOrderConformance {
493    pub design_v1_ready: bool,
494    pub parent_relationships: usize,
495    pub parent_group_violations: Vec<RestoreGroupViolation>,
496}
497
498impl RestoreOrderConformance {
499    /// Summarize whether restore groups preserve parent-before-child ordering.
500    fn from_manifest(manifest: &FleetBackupManifest) -> Self {
501        let members_by_id = manifest
502            .fleet
503            .members
504            .iter()
505            .map(|member| (member.canister_id.as_str(), member))
506            .collect::<BTreeMap<_, _>>();
507        let mut parent_relationships = 0;
508        let mut parent_group_violations = Vec::new();
509
510        for member in &manifest.fleet.members {
511            let Some(parent_id) = member.parent_canister_id.as_deref() else {
512                continue;
513            };
514            let Some(parent) = members_by_id.get(parent_id) else {
515                continue;
516            };
517            parent_relationships += 1;
518            if parent.restore_group > member.restore_group {
519                parent_group_violations.push(RestoreGroupViolation {
520                    parent_canister_id: parent.canister_id.clone(),
521                    child_canister_id: member.canister_id.clone(),
522                    parent_restore_group: parent.restore_group,
523                    child_restore_group: member.restore_group,
524                });
525            }
526        }
527
528        let design_v1_ready = parent_group_violations.is_empty();
529
530        Self {
531            design_v1_ready,
532            parent_relationships,
533            parent_group_violations,
534        }
535    }
536}
537
538///
539/// RestoreGroupViolation
540///
541
542#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
543pub struct RestoreGroupViolation {
544    pub parent_canister_id: String,
545    pub child_canister_id: String,
546    pub parent_restore_group: u16,
547    pub child_restore_group: u16,
548}
549
550///
551/// ToolMetadata
552///
553
554#[derive(Clone, Debug, Deserialize, Serialize)]
555pub struct ToolMetadata {
556    pub name: String,
557    pub version: String,
558}
559
560impl ToolMetadata {
561    /// Validate that the manifest names the tool that produced it.
562    pub(crate) fn validate(&self) -> Result<(), ManifestValidationError> {
563        validate_nonempty("tool.name", &self.name)?;
564        validate_nonempty("tool.version", &self.version)
565    }
566}
567
568///
569/// SourceMetadata
570///
571
572#[derive(Clone, Debug, Deserialize, Serialize)]
573pub struct SourceMetadata {
574    pub environment: String,
575    pub root_canister: String,
576}
577
578impl SourceMetadata {
579    /// Validate the source environment and root canister identity.
580    fn validate(&self) -> Result<(), ManifestValidationError> {
581        validate_nonempty("source.environment", &self.environment)?;
582        validate_principal("source.root_canister", &self.root_canister)
583    }
584}
585
586///
587/// ConsistencySection
588///
589
590#[derive(Clone, Debug, Deserialize, Serialize)]
591pub struct ConsistencySection {
592    pub mode: ConsistencyMode,
593    pub backup_units: Vec<BackupUnit>,
594}
595
596impl ConsistencySection {
597    /// Validate consistency mode and every declared backup unit.
598    fn validate(&self) -> Result<(), ManifestValidationError> {
599        if self.backup_units.is_empty() {
600            return Err(ManifestValidationError::EmptyCollection(
601                "consistency.backup_units",
602            ));
603        }
604
605        let mut unit_ids = BTreeSet::new();
606        for unit in &self.backup_units {
607            unit.validate(&self.mode)?;
608            if !unit_ids.insert(unit.unit_id.clone()) {
609                return Err(ManifestValidationError::DuplicateBackupUnitId(
610                    unit.unit_id.clone(),
611                ));
612            }
613        }
614
615        Ok(())
616    }
617}
618
619///
620/// ConsistencyMode
621///
622
623#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
624#[serde(rename_all = "kebab-case")]
625pub enum ConsistencyMode {
626    CrashConsistent,
627    QuiescedUnit,
628}
629
630///
631/// BackupUnit
632///
633
634#[derive(Clone, Debug, Deserialize, Serialize)]
635pub struct BackupUnit {
636    pub unit_id: String,
637    pub kind: BackupUnitKind,
638    pub roles: Vec<String>,
639    pub consistency_reason: Option<String>,
640    pub dependency_closure: Vec<String>,
641    pub topology_validation: String,
642    pub quiescence_strategy: Option<String>,
643}
644
645impl BackupUnit {
646    /// Validate the declared unit boundary and quiescence metadata.
647    fn validate(&self, mode: &ConsistencyMode) -> Result<(), ManifestValidationError> {
648        validate_nonempty("consistency.backup_units[].unit_id", &self.unit_id)?;
649        validate_nonempty(
650            "consistency.backup_units[].topology_validation",
651            &self.topology_validation,
652        )?;
653
654        if self.roles.is_empty() {
655            return Err(ManifestValidationError::EmptyCollection(
656                "consistency.backup_units[].roles",
657            ));
658        }
659
660        for role in &self.roles {
661            validate_nonempty("consistency.backup_units[].roles[]", role)?;
662        }
663        validate_unique_values("consistency.backup_units[].roles[]", &self.roles, |role| {
664            ManifestValidationError::DuplicateBackupUnitRole {
665                unit_id: self.unit_id.clone(),
666                role: role.to_string(),
667            }
668        })?;
669
670        for dependency in &self.dependency_closure {
671            validate_nonempty(
672                "consistency.backup_units[].dependency_closure[]",
673                dependency,
674            )?;
675        }
676        validate_unique_values(
677            "consistency.backup_units[].dependency_closure[]",
678            &self.dependency_closure,
679            |dependency| ManifestValidationError::DuplicateBackupUnitDependency {
680                unit_id: self.unit_id.clone(),
681                dependency: dependency.to_string(),
682            },
683        )?;
684
685        if matches!(self.kind, BackupUnitKind::Flat) {
686            validate_required_option(
687                "consistency.backup_units[].consistency_reason",
688                self.consistency_reason.as_deref(),
689            )?;
690        }
691
692        if matches!(mode, ConsistencyMode::QuiescedUnit) {
693            validate_required_option(
694                "consistency.backup_units[].quiescence_strategy",
695                self.quiescence_strategy.as_deref(),
696            )?;
697        }
698
699        Ok(())
700    }
701}
702
703///
704/// BackupUnitKind
705///
706
707#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
708#[serde(rename_all = "kebab-case")]
709pub enum BackupUnitKind {
710    WholeFleet,
711    ControlPlaneSubset,
712    SubtreeRooted,
713    Flat,
714}
715
716///
717/// FleetSection
718///
719
720#[derive(Clone, Debug, Deserialize, Serialize)]
721pub struct FleetSection {
722    pub topology_hash_algorithm: String,
723    pub topology_hash_input: String,
724    pub discovery_topology_hash: String,
725    pub pre_snapshot_topology_hash: String,
726    pub topology_hash: String,
727    pub members: Vec<FleetMember>,
728}
729
730impl FleetSection {
731    /// Validate topology hash invariants and member uniqueness.
732    pub(crate) fn validate(&self) -> Result<(), ManifestValidationError> {
733        validate_nonempty(
734            "fleet.topology_hash_algorithm",
735            &self.topology_hash_algorithm,
736        )?;
737        if self.topology_hash_algorithm != SHA256_ALGORITHM {
738            return Err(ManifestValidationError::UnsupportedHashAlgorithm(
739                self.topology_hash_algorithm.clone(),
740            ));
741        }
742
743        validate_nonempty("fleet.topology_hash_input", &self.topology_hash_input)?;
744        validate_hash(
745            "fleet.discovery_topology_hash",
746            &self.discovery_topology_hash,
747        )?;
748        validate_hash(
749            "fleet.pre_snapshot_topology_hash",
750            &self.pre_snapshot_topology_hash,
751        )?;
752        validate_hash("fleet.topology_hash", &self.topology_hash)?;
753
754        if self.discovery_topology_hash != self.pre_snapshot_topology_hash {
755            return Err(ManifestValidationError::TopologyHashMismatch {
756                discovery: self.discovery_topology_hash.clone(),
757                pre_snapshot: self.pre_snapshot_topology_hash.clone(),
758            });
759        }
760
761        if self.topology_hash != self.discovery_topology_hash {
762            return Err(ManifestValidationError::AcceptedTopologyHashMismatch {
763                accepted: self.topology_hash.clone(),
764                discovery: self.discovery_topology_hash.clone(),
765            });
766        }
767
768        if self.members.is_empty() {
769            return Err(ManifestValidationError::EmptyCollection("fleet.members"));
770        }
771
772        let mut canister_ids = BTreeSet::new();
773        for member in &self.members {
774            member.validate()?;
775            if !canister_ids.insert(member.canister_id.clone()) {
776                return Err(ManifestValidationError::DuplicateCanisterId(
777                    member.canister_id.clone(),
778                ));
779            }
780        }
781
782        Ok(())
783    }
784}
785
786///
787/// FleetMember
788///
789
790#[derive(Clone, Debug, Deserialize, Serialize)]
791pub struct FleetMember {
792    pub role: String,
793    pub canister_id: String,
794    pub parent_canister_id: Option<String>,
795    pub subnet_canister_id: Option<String>,
796    pub controller_hint: Option<String>,
797    pub identity_mode: IdentityMode,
798    pub restore_group: u16,
799    pub verification_class: String,
800    pub verification_checks: Vec<VerificationCheck>,
801    pub source_snapshot: SourceSnapshot,
802}
803
804impl FleetMember {
805    /// Validate one restore member projection from the manifest.
806    fn validate(&self) -> Result<(), ManifestValidationError> {
807        validate_nonempty("fleet.members[].role", &self.role)?;
808        validate_principal("fleet.members[].canister_id", &self.canister_id)?;
809        validate_optional_principal(
810            "fleet.members[].parent_canister_id",
811            self.parent_canister_id.as_deref(),
812        )?;
813        validate_optional_principal(
814            "fleet.members[].subnet_canister_id",
815            self.subnet_canister_id.as_deref(),
816        )?;
817        validate_optional_principal(
818            "fleet.members[].controller_hint",
819            self.controller_hint.as_deref(),
820        )?;
821        validate_nonempty(
822            "fleet.members[].verification_class",
823            &self.verification_class,
824        )?;
825
826        if self.verification_checks.is_empty() {
827            return Err(ManifestValidationError::MissingMemberVerificationChecks(
828                self.canister_id.clone(),
829            ));
830        }
831
832        for check in &self.verification_checks {
833            check.validate()?;
834        }
835
836        self.source_snapshot.validate()
837    }
838}
839
840///
841/// IdentityMode
842///
843
844#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
845#[serde(rename_all = "kebab-case")]
846pub enum IdentityMode {
847    Fixed,
848    Relocatable,
849}
850
851///
852/// SourceSnapshot
853///
854
855#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
856pub struct SourceSnapshot {
857    pub snapshot_id: String,
858    pub module_hash: Option<String>,
859    pub wasm_hash: Option<String>,
860    pub code_version: Option<String>,
861    pub artifact_path: String,
862    pub checksum_algorithm: String,
863    #[serde(default)]
864    pub checksum: Option<String>,
865}
866
867impl SourceSnapshot {
868    /// Validate source snapshot provenance and artifact checksum metadata.
869    fn validate(&self) -> Result<(), ManifestValidationError> {
870        validate_nonempty(
871            "fleet.members[].source_snapshot.snapshot_id",
872            &self.snapshot_id,
873        )?;
874        validate_optional_nonempty(
875            "fleet.members[].source_snapshot.module_hash",
876            self.module_hash.as_deref(),
877        )?;
878        validate_optional_nonempty(
879            "fleet.members[].source_snapshot.wasm_hash",
880            self.wasm_hash.as_deref(),
881        )?;
882        validate_optional_nonempty(
883            "fleet.members[].source_snapshot.code_version",
884            self.code_version.as_deref(),
885        )?;
886        validate_nonempty(
887            "fleet.members[].source_snapshot.artifact_path",
888            &self.artifact_path,
889        )?;
890        validate_nonempty(
891            "fleet.members[].source_snapshot.checksum_algorithm",
892            &self.checksum_algorithm,
893        )?;
894        if self.checksum_algorithm != SHA256_ALGORITHM {
895            return Err(ManifestValidationError::UnsupportedHashAlgorithm(
896                self.checksum_algorithm.clone(),
897            ));
898        }
899        validate_optional_hash(
900            "fleet.members[].source_snapshot.checksum",
901            self.checksum.as_deref(),
902        )?;
903        Ok(())
904    }
905}
906
907///
908/// VerificationPlan
909///
910
911#[derive(Clone, Debug, Default, Deserialize, Serialize)]
912pub struct VerificationPlan {
913    pub fleet_checks: Vec<VerificationCheck>,
914    pub member_checks: Vec<MemberVerificationChecks>,
915}
916
917impl VerificationPlan {
918    /// Validate all declarative verification checks.
919    fn validate(&self) -> Result<(), ManifestValidationError> {
920        for check in &self.fleet_checks {
921            check.validate()?;
922        }
923        for member in &self.member_checks {
924            member.validate()?;
925        }
926        Ok(())
927    }
928}
929
930///
931/// MemberVerificationChecks
932///
933
934#[derive(Clone, Debug, Deserialize, Serialize)]
935pub struct MemberVerificationChecks {
936    pub role: String,
937    pub checks: Vec<VerificationCheck>,
938}
939
940impl MemberVerificationChecks {
941    /// Validate one role-scoped verification check group.
942    fn validate(&self) -> Result<(), ManifestValidationError> {
943        validate_nonempty("verification.member_checks[].role", &self.role)?;
944        if self.checks.is_empty() {
945            return Err(ManifestValidationError::EmptyCollection(
946                "verification.member_checks[].checks",
947            ));
948        }
949        for check in &self.checks {
950            check.validate()?;
951        }
952        Ok(())
953    }
954}
955
956///
957/// VerificationCheck
958///
959
960#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
961pub struct VerificationCheck {
962    pub kind: String,
963    pub method: Option<String>,
964    pub roles: Vec<String>,
965}
966
967impl VerificationCheck {
968    /// Validate one concrete verification check.
969    fn validate(&self) -> Result<(), ManifestValidationError> {
970        validate_nonempty("verification.check.kind", &self.kind)?;
971        validate_optional_nonempty("verification.check.method", self.method.as_deref())?;
972        for role in &self.roles {
973            validate_nonempty("verification.check.roles[]", role)?;
974        }
975        validate_unique_values("verification.check.roles[]", &self.roles, |role| {
976            ManifestValidationError::DuplicateVerificationCheckRole {
977                kind: self.kind.clone(),
978                role: role.to_string(),
979            }
980        })?;
981        Ok(())
982    }
983}
984
985///
986/// ManifestValidationError
987///
988
989#[derive(Debug, ThisError)]
990pub enum ManifestValidationError {
991    #[error("unsupported manifest version {0}")]
992    UnsupportedManifestVersion(u16),
993
994    #[error("field {0} must not be empty")]
995    EmptyField(&'static str),
996
997    #[error("collection {0} must not be empty")]
998    EmptyCollection(&'static str),
999
1000    #[error("field {field} must be a valid principal: {value}")]
1001    InvalidPrincipal { field: &'static str, value: String },
1002
1003    #[error("field {0} must be a non-empty sha256 hex string")]
1004    InvalidHash(&'static str),
1005
1006    #[error("unsupported hash algorithm {0}")]
1007    UnsupportedHashAlgorithm(String),
1008
1009    #[error("topology hash mismatch between discovery {discovery} and pre-snapshot {pre_snapshot}")]
1010    TopologyHashMismatch {
1011        discovery: String,
1012        pre_snapshot: String,
1013    },
1014
1015    #[error("accepted topology hash {accepted} does not match discovery hash {discovery}")]
1016    AcceptedTopologyHashMismatch { accepted: String, discovery: String },
1017
1018    #[error("duplicate canister id {0}")]
1019    DuplicateCanisterId(String),
1020
1021    #[error("duplicate backup unit id {0}")]
1022    DuplicateBackupUnitId(String),
1023
1024    #[error("backup unit {unit_id} repeats role {role}")]
1025    DuplicateBackupUnitRole { unit_id: String, role: String },
1026
1027    #[error("backup unit {unit_id} repeats dependency {dependency}")]
1028    DuplicateBackupUnitDependency { unit_id: String, dependency: String },
1029
1030    #[error("fleet member {0} has no concrete verification checks")]
1031    MissingMemberVerificationChecks(String),
1032
1033    #[error("backup unit {unit_id} references unknown role {role}")]
1034    UnknownBackupUnitRole { unit_id: String, role: String },
1035
1036    #[error("backup unit {unit_id} references unknown dependency {dependency}")]
1037    UnknownBackupUnitDependency { unit_id: String, dependency: String },
1038
1039    #[error("fleet role {role} is not covered by any backup unit")]
1040    BackupUnitCoverageMissingRole { role: String },
1041
1042    #[error("verification plan references unknown role {role}")]
1043    UnknownVerificationRole { role: String },
1044
1045    #[error("duplicate member verification role {0}")]
1046    DuplicateMemberVerificationRole(String),
1047
1048    #[error("verification check {kind} repeats role {role}")]
1049    DuplicateVerificationCheckRole { kind: String, role: String },
1050
1051    #[error("whole-fleet backup unit {unit_id} omits fleet role {role}")]
1052    WholeFleetUnitMissingRole { unit_id: String, role: String },
1053
1054    #[error("subtree backup unit {unit_id} is not connected")]
1055    SubtreeBackupUnitNotConnected { unit_id: String },
1056
1057    #[error(
1058        "subtree backup unit {unit_id} includes parent {parent} but omits descendant {descendant}"
1059    )]
1060    SubtreeBackupUnitMissingDescendant {
1061        unit_id: String,
1062        parent: String,
1063        descendant: String,
1064    },
1065}
1066
1067// Return whether every fleet role is included in at least one backup unit.
1068fn all_fleet_roles_covered(manifest: &FleetBackupManifest) -> bool {
1069    let fleet_roles = manifest
1070        .fleet
1071        .members
1072        .iter()
1073        .map(|member| member.role.as_str())
1074        .collect::<BTreeSet<_>>();
1075    let covered_roles = manifest
1076        .consistency
1077        .backup_units
1078        .iter()
1079        .flat_map(|unit| unit.roles.iter().map(String::as_str))
1080        .collect::<BTreeSet<_>>();
1081
1082    fleet_roles.iter().all(|role| covered_roles.contains(role))
1083}
1084
1085// Validate cross-section backup unit references after local section checks pass.
1086fn validate_consistency_against_fleet(
1087    consistency: &ConsistencySection,
1088    fleet: &FleetSection,
1089) -> Result<(), ManifestValidationError> {
1090    let fleet_roles = fleet
1091        .members
1092        .iter()
1093        .map(|member| member.role.as_str())
1094        .collect::<BTreeSet<_>>();
1095    let mut covered_roles = BTreeSet::new();
1096
1097    for unit in &consistency.backup_units {
1098        for role in &unit.roles {
1099            if !fleet_roles.contains(role.as_str()) {
1100                return Err(ManifestValidationError::UnknownBackupUnitRole {
1101                    unit_id: unit.unit_id.clone(),
1102                    role: role.clone(),
1103                });
1104            }
1105            covered_roles.insert(role.as_str());
1106        }
1107
1108        for dependency in &unit.dependency_closure {
1109            if !fleet_roles.contains(dependency.as_str()) {
1110                return Err(ManifestValidationError::UnknownBackupUnitDependency {
1111                    unit_id: unit.unit_id.clone(),
1112                    dependency: dependency.clone(),
1113                });
1114            }
1115        }
1116
1117        validate_backup_unit_topology(unit, fleet, &fleet_roles)?;
1118    }
1119
1120    for role in &fleet_roles {
1121        if !covered_roles.contains(role) {
1122            return Err(ManifestValidationError::BackupUnitCoverageMissingRole {
1123                role: (*role).to_string(),
1124            });
1125        }
1126    }
1127
1128    Ok(())
1129}
1130
1131// Validate verification role references after fleet roles are known.
1132fn validate_verification_against_fleet(
1133    verification: &VerificationPlan,
1134    fleet: &FleetSection,
1135) -> Result<(), ManifestValidationError> {
1136    let fleet_roles = fleet
1137        .members
1138        .iter()
1139        .map(|member| member.role.as_str())
1140        .collect::<BTreeSet<_>>();
1141
1142    for check in &verification.fleet_checks {
1143        validate_verification_check_roles(check, &fleet_roles)?;
1144    }
1145
1146    for member in &fleet.members {
1147        for check in &member.verification_checks {
1148            validate_verification_check_roles(check, &fleet_roles)?;
1149        }
1150    }
1151
1152    let mut member_check_roles = BTreeSet::new();
1153    for member in &verification.member_checks {
1154        if !fleet_roles.contains(member.role.as_str()) {
1155            return Err(ManifestValidationError::UnknownVerificationRole {
1156                role: member.role.clone(),
1157            });
1158        }
1159        if !member_check_roles.insert(member.role.as_str()) {
1160            return Err(ManifestValidationError::DuplicateMemberVerificationRole(
1161                member.role.clone(),
1162            ));
1163        }
1164        for check in &member.checks {
1165            validate_verification_check_roles(check, &fleet_roles)?;
1166        }
1167    }
1168
1169    Ok(())
1170}
1171
1172// Validate every role filter in one verification check.
1173fn validate_verification_check_roles(
1174    check: &VerificationCheck,
1175    fleet_roles: &BTreeSet<&str>,
1176) -> Result<(), ManifestValidationError> {
1177    for role in &check.roles {
1178        if !fleet_roles.contains(role.as_str()) {
1179            return Err(ManifestValidationError::UnknownVerificationRole { role: role.clone() });
1180        }
1181    }
1182
1183    Ok(())
1184}
1185
1186// Validate backup unit topology promises against manifest membership.
1187fn validate_backup_unit_topology(
1188    unit: &BackupUnit,
1189    fleet: &FleetSection,
1190    fleet_roles: &BTreeSet<&str>,
1191) -> Result<(), ManifestValidationError> {
1192    match &unit.kind {
1193        BackupUnitKind::WholeFleet => validate_whole_fleet_unit(unit, fleet_roles),
1194        BackupUnitKind::SubtreeRooted => validate_subtree_unit(unit, fleet),
1195        BackupUnitKind::ControlPlaneSubset | BackupUnitKind::Flat => Ok(()),
1196    }
1197}
1198
1199// Ensure whole-fleet units cover every role declared by the fleet.
1200fn validate_whole_fleet_unit(
1201    unit: &BackupUnit,
1202    fleet_roles: &BTreeSet<&str>,
1203) -> Result<(), ManifestValidationError> {
1204    let unit_roles = unit
1205        .roles
1206        .iter()
1207        .map(String::as_str)
1208        .collect::<BTreeSet<_>>();
1209    for role in fleet_roles {
1210        if !unit_roles.contains(role) {
1211            return Err(ManifestValidationError::WholeFleetUnitMissingRole {
1212                unit_id: unit.unit_id.clone(),
1213                role: (*role).to_string(),
1214            });
1215        }
1216    }
1217
1218    Ok(())
1219}
1220
1221// Ensure subtree units are connected and closed under descendants.
1222fn validate_subtree_unit(
1223    unit: &BackupUnit,
1224    fleet: &FleetSection,
1225) -> Result<(), ManifestValidationError> {
1226    let unit_roles = unit
1227        .roles
1228        .iter()
1229        .map(String::as_str)
1230        .collect::<BTreeSet<_>>();
1231    let members_by_id = fleet
1232        .members
1233        .iter()
1234        .map(|member| (member.canister_id.as_str(), member))
1235        .collect::<BTreeMap<_, _>>();
1236    let unit_member_ids = fleet
1237        .members
1238        .iter()
1239        .filter(|member| unit_roles.contains(member.role.as_str()))
1240        .map(|member| member.canister_id.as_str())
1241        .collect::<BTreeSet<_>>();
1242
1243    let root_count = fleet
1244        .members
1245        .iter()
1246        .filter(|member| unit_member_ids.contains(member.canister_id.as_str()))
1247        .filter(|member| {
1248            member
1249                .parent_canister_id
1250                .as_deref()
1251                .is_none_or(|parent| !unit_member_ids.contains(parent))
1252        })
1253        .count();
1254    if root_count != 1 {
1255        return Err(ManifestValidationError::SubtreeBackupUnitNotConnected {
1256            unit_id: unit.unit_id.clone(),
1257        });
1258    }
1259
1260    for member in &fleet.members {
1261        if unit_member_ids.contains(member.canister_id.as_str()) {
1262            continue;
1263        }
1264
1265        if let Some(parent) = first_unit_ancestor(member, &members_by_id, &unit_member_ids) {
1266            return Err(
1267                ManifestValidationError::SubtreeBackupUnitMissingDescendant {
1268                    unit_id: unit.unit_id.clone(),
1269                    parent: parent.to_string(),
1270                    descendant: member.canister_id.clone(),
1271                },
1272            );
1273        }
1274    }
1275
1276    Ok(())
1277}
1278
1279// Return the nearest selected ancestor for a member outside a subtree unit.
1280fn first_unit_ancestor<'a>(
1281    member: &'a FleetMember,
1282    members_by_id: &BTreeMap<&'a str, &'a FleetMember>,
1283    unit_member_ids: &BTreeSet<&'a str>,
1284) -> Option<&'a str> {
1285    let mut visited = BTreeSet::new();
1286    let mut parent = member.parent_canister_id.as_deref();
1287    while let Some(parent_id) = parent {
1288        if unit_member_ids.contains(parent_id) {
1289            return Some(parent_id);
1290        }
1291        if !visited.insert(parent_id) {
1292            return None;
1293        }
1294        parent = members_by_id
1295            .get(parent_id)
1296            .and_then(|ancestor| ancestor.parent_canister_id.as_deref());
1297    }
1298
1299    None
1300}
1301
1302// Validate the manifest format version before checking nested fields.
1303const fn validate_manifest_version(version: u16) -> Result<(), ManifestValidationError> {
1304    if version == SUPPORTED_MANIFEST_VERSION {
1305        Ok(())
1306    } else {
1307        Err(ManifestValidationError::UnsupportedManifestVersion(version))
1308    }
1309}
1310
1311// Validate required string fields after trimming whitespace.
1312fn validate_nonempty(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
1313    if value.trim().is_empty() {
1314        Err(ManifestValidationError::EmptyField(field))
1315    } else {
1316        Ok(())
1317    }
1318}
1319
1320// Validate optional string fields only when present.
1321fn validate_optional_nonempty(
1322    field: &'static str,
1323    value: Option<&str>,
1324) -> Result<(), ManifestValidationError> {
1325    if let Some(value) = value {
1326        validate_nonempty(field, value)?;
1327    }
1328    Ok(())
1329}
1330
1331// Validate required string fields that are represented as optional manifest fields.
1332fn validate_required_option(
1333    field: &'static str,
1334    value: Option<&str>,
1335) -> Result<(), ManifestValidationError> {
1336    match value {
1337        Some(value) => validate_nonempty(field, value),
1338        None => Err(ManifestValidationError::EmptyField(field)),
1339    }
1340}
1341
1342// Validate that a string list does not repeat values.
1343fn validate_unique_values<F>(
1344    field: &'static str,
1345    values: &[String],
1346    error: F,
1347) -> Result<(), ManifestValidationError>
1348where
1349    F: Fn(&str) -> ManifestValidationError,
1350{
1351    let mut seen = BTreeSet::new();
1352    for value in values {
1353        validate_nonempty(field, value)?;
1354        if !seen.insert(value.as_str()) {
1355            return Err(error(value));
1356        }
1357    }
1358
1359    Ok(())
1360}
1361
1362// Validate textual principal fields used in JSON manifests.
1363fn validate_principal(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
1364    validate_nonempty(field, value)?;
1365    Principal::from_str(value)
1366        .map(|_| ())
1367        .map_err(|_| ManifestValidationError::InvalidPrincipal {
1368            field,
1369            value: value.to_string(),
1370        })
1371}
1372
1373// Validate optional textual principal fields used in JSON manifests.
1374fn validate_optional_principal(
1375    field: &'static str,
1376    value: Option<&str>,
1377) -> Result<(), ManifestValidationError> {
1378    if let Some(value) = value {
1379        validate_principal(field, value)?;
1380    }
1381    Ok(())
1382}
1383
1384// Validate SHA-256 hex values used for topology and artifact compatibility.
1385fn validate_hash(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
1386    const SHA256_HEX_LEN: usize = 64;
1387    validate_nonempty(field, value)?;
1388    if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
1389        Ok(())
1390    } else {
1391        Err(ManifestValidationError::InvalidHash(field))
1392    }
1393}
1394
1395// Validate optional SHA-256 hex values only when present.
1396fn validate_optional_hash(
1397    field: &'static str,
1398    value: Option<&str>,
1399) -> Result<(), ManifestValidationError> {
1400    if let Some(value) = value {
1401        validate_hash(field, value)?;
1402    }
1403    Ok(())
1404}
1405
1406#[cfg(test)]
1407mod tests;