Skip to main content

canic_backup/manifest/
mod.rs

1use candid::Principal;
2use serde::{Deserialize, Serialize};
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    str::FromStr,
6};
7use thiserror::Error as ThisError;
8
9const SUPPORTED_MANIFEST_VERSION: u16 = 1;
10const SHA256_ALGORITHM: &str = "sha256";
11const DESIGN_V1: &str = "0.30-design-v1";
12const TOPOLOGY_HASH_INPUT_V1: &str = "sorted(pid,parent_pid,role,module_hash)";
13
14///
15/// FleetBackupManifest
16///
17
18#[derive(Clone, Debug, Deserialize, Serialize)]
19pub struct FleetBackupManifest {
20    pub manifest_version: u16,
21    pub backup_id: String,
22    pub created_at: String,
23    pub tool: ToolMetadata,
24    pub source: SourceMetadata,
25    pub consistency: ConsistencySection,
26    pub fleet: FleetSection,
27    pub verification: VerificationPlan,
28}
29
30impl FleetBackupManifest {
31    /// Validate the manifest-level contract before backup finalization or restore planning.
32    pub fn validate(&self) -> Result<(), ManifestValidationError> {
33        validate_manifest_version(self.manifest_version)?;
34        validate_nonempty("backup_id", &self.backup_id)?;
35        validate_nonempty("created_at", &self.created_at)?;
36        self.tool.validate()?;
37        self.source.validate()?;
38        self.consistency.validate()?;
39        self.fleet.validate()?;
40        self.verification.validate()?;
41        validate_consistency_against_fleet(&self.consistency, &self.fleet)?;
42        validate_verification_against_fleet(&self.verification, &self.fleet)?;
43        Ok(())
44    }
45
46    /// Build a design-conformance report for operator preflight checks.
47    #[must_use]
48    pub fn design_conformance_report(&self) -> ManifestDesignConformanceReport {
49        ManifestDesignConformanceReport::from_manifest(self)
50    }
51}
52
53///
54/// ManifestDesignConformanceReport
55///
56
57#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
58pub struct ManifestDesignConformanceReport {
59    pub design_version: String,
60    pub design_v1_ready: bool,
61    pub topology: TopologyConformance,
62    pub backup_units: BackupUnitConformance,
63    pub quiescence: QuiescenceConformance,
64    pub verification: VerificationConformance,
65    pub identity: IdentityConformance,
66    pub snapshot_provenance: SnapshotProvenanceConformance,
67    pub restore_order: RestoreOrderConformance,
68}
69
70impl ManifestDesignConformanceReport {
71    /// Build one report from an already-loaded manifest.
72    fn from_manifest(manifest: &FleetBackupManifest) -> Self {
73        let topology = TopologyConformance::from_manifest(manifest);
74        let backup_units = BackupUnitConformance::from_manifest(manifest);
75        let quiescence = QuiescenceConformance::from_manifest(manifest);
76        let verification = VerificationConformance::from_manifest(manifest);
77        let identity = IdentityConformance::from_manifest(manifest);
78        let snapshot_provenance = SnapshotProvenanceConformance::from_manifest(manifest);
79        let restore_order = RestoreOrderConformance::from_manifest(manifest);
80        let design_v1_ready = topology.design_v1_ready
81            && backup_units.design_v1_ready
82            && quiescence.design_v1_ready
83            && verification.design_v1_ready
84            && snapshot_provenance.design_v1_ready
85            && restore_order.design_v1_ready;
86
87        Self {
88            design_version: DESIGN_V1.to_string(),
89            design_v1_ready,
90            topology,
91            backup_units,
92            quiescence,
93            verification,
94            identity,
95            snapshot_provenance,
96            restore_order,
97        }
98    }
99}
100
101///
102/// TopologyConformance
103///
104
105#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
106#[allow(clippy::struct_excessive_bools)]
107pub struct TopologyConformance {
108    pub design_v1_ready: bool,
109    pub algorithm_sha256: bool,
110    pub canonical_input: bool,
111    pub discovery_matches_pre_snapshot: bool,
112    pub accepted_matches_discovery: bool,
113}
114
115impl TopologyConformance {
116    /// Summarize topology hash stability and canonical input metadata.
117    fn from_manifest(manifest: &FleetBackupManifest) -> Self {
118        let algorithm_sha256 = manifest.fleet.topology_hash_algorithm == SHA256_ALGORITHM;
119        let canonical_input = manifest.fleet.topology_hash_input == TOPOLOGY_HASH_INPUT_V1;
120        let discovery_matches_pre_snapshot =
121            manifest.fleet.discovery_topology_hash == manifest.fleet.pre_snapshot_topology_hash;
122        let accepted_matches_discovery =
123            manifest.fleet.topology_hash == manifest.fleet.discovery_topology_hash;
124        let design_v1_ready = algorithm_sha256
125            && canonical_input
126            && discovery_matches_pre_snapshot
127            && accepted_matches_discovery;
128
129        Self {
130            design_v1_ready,
131            algorithm_sha256,
132            canonical_input,
133            discovery_matches_pre_snapshot,
134            accepted_matches_discovery,
135        }
136    }
137}
138
139///
140/// BackupUnitConformance
141///
142
143#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
144#[allow(clippy::struct_excessive_bools)]
145pub struct BackupUnitConformance {
146    pub design_v1_ready: bool,
147    pub unit_count: usize,
148    pub all_units_have_roles: bool,
149    pub all_units_have_topology_validation: bool,
150    pub all_roles_covered: bool,
151    pub flat_units: usize,
152    pub flat_units_with_reason: usize,
153    pub subtree_units: usize,
154    pub subtree_units_declared_closed: usize,
155}
156
157impl BackupUnitConformance {
158    /// Summarize backup-unit boundary metadata required by the design.
159    fn from_manifest(manifest: &FleetBackupManifest) -> Self {
160        let unit_count = manifest.consistency.backup_units.len();
161        let all_units_have_roles = manifest
162            .consistency
163            .backup_units
164            .iter()
165            .all(|unit| !unit.roles.is_empty());
166        let all_units_have_topology_validation = manifest
167            .consistency
168            .backup_units
169            .iter()
170            .all(|unit| !unit.topology_validation.trim().is_empty());
171        let all_roles_covered = all_fleet_roles_covered(manifest);
172        let flat_units = manifest
173            .consistency
174            .backup_units
175            .iter()
176            .filter(|unit| matches!(unit.kind, BackupUnitKind::Flat))
177            .count();
178        let flat_units_with_reason = manifest
179            .consistency
180            .backup_units
181            .iter()
182            .filter(|unit| matches!(unit.kind, BackupUnitKind::Flat))
183            .filter(|unit| {
184                unit.consistency_reason
185                    .as_deref()
186                    .is_some_and(|reason| !reason.trim().is_empty())
187            })
188            .count();
189        let subtree_units = manifest
190            .consistency
191            .backup_units
192            .iter()
193            .filter(|unit| matches!(unit.kind, BackupUnitKind::SubtreeRooted))
194            .count();
195        let subtree_units_declared_closed = manifest
196            .consistency
197            .backup_units
198            .iter()
199            .filter(|unit| matches!(unit.kind, BackupUnitKind::SubtreeRooted))
200            .filter(|unit| unit.topology_validation == "subtree-closed")
201            .count();
202        let design_v1_ready = unit_count > 0
203            && all_units_have_roles
204            && all_units_have_topology_validation
205            && all_roles_covered
206            && flat_units == flat_units_with_reason
207            && subtree_units == subtree_units_declared_closed;
208
209        Self {
210            design_v1_ready,
211            unit_count,
212            all_units_have_roles,
213            all_units_have_topology_validation,
214            all_roles_covered,
215            flat_units,
216            flat_units_with_reason,
217            subtree_units,
218            subtree_units_declared_closed,
219        }
220    }
221}
222
223///
224/// QuiescenceConformance
225///
226
227#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
228#[allow(clippy::struct_excessive_bools)]
229pub struct QuiescenceConformance {
230    pub design_v1_ready: bool,
231    pub mode: ConsistencyMode,
232    pub quiescence_required: bool,
233    pub unit_count: usize,
234    pub units_with_strategy: usize,
235    pub all_required_units_have_strategy: bool,
236}
237
238impl QuiescenceConformance {
239    /// Summarize the explicit quiescence boundary for the backup.
240    fn from_manifest(manifest: &FleetBackupManifest) -> Self {
241        let quiescence_required =
242            matches!(manifest.consistency.mode, ConsistencyMode::QuiescedUnit);
243        let unit_count = manifest.consistency.backup_units.len();
244        let units_with_strategy = manifest
245            .consistency
246            .backup_units
247            .iter()
248            .filter(|unit| {
249                unit.quiescence_strategy
250                    .as_deref()
251                    .is_some_and(|strategy| !strategy.trim().is_empty())
252            })
253            .count();
254        let all_required_units_have_strategy =
255            !quiescence_required || units_with_strategy == unit_count;
256        let design_v1_ready = all_required_units_have_strategy;
257
258        Self {
259            design_v1_ready,
260            mode: manifest.consistency.mode.clone(),
261            quiescence_required,
262            unit_count,
263            units_with_strategy,
264            all_required_units_have_strategy,
265        }
266    }
267}
268
269///
270/// VerificationConformance
271///
272
273#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
274pub struct VerificationConformance {
275    pub design_v1_ready: bool,
276    pub member_count: usize,
277    pub members_with_checks: usize,
278    pub all_members_have_checks: bool,
279    pub fleet_check_count: usize,
280    pub role_check_group_count: usize,
281}
282
283impl VerificationConformance {
284    /// Summarize concrete verification coverage for restored members.
285    fn from_manifest(manifest: &FleetBackupManifest) -> Self {
286        let member_count = manifest.fleet.members.len();
287        let members_with_checks = manifest
288            .fleet
289            .members
290            .iter()
291            .filter(|member| !member.verification_checks.is_empty())
292            .count();
293        let all_members_have_checks = member_count == members_with_checks;
294        let fleet_check_count = manifest.verification.fleet_checks.len();
295        let role_check_group_count = manifest.verification.member_checks.len();
296        let design_v1_ready = member_count > 0 && all_members_have_checks;
297
298        Self {
299            design_v1_ready,
300            member_count,
301            members_with_checks,
302            all_members_have_checks,
303            fleet_check_count,
304            role_check_group_count,
305        }
306    }
307}
308
309///
310/// IdentityConformance
311///
312
313#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
314pub struct IdentityConformance {
315    pub fixed_members: usize,
316    pub relocatable_members: usize,
317}
318
319impl IdentityConformance {
320    /// Summarize restore identity modes carried by fleet members.
321    fn from_manifest(manifest: &FleetBackupManifest) -> Self {
322        let fixed_members = manifest
323            .fleet
324            .members
325            .iter()
326            .filter(|member| matches!(member.identity_mode, IdentityMode::Fixed))
327            .count();
328        let relocatable_members = manifest
329            .fleet
330            .members
331            .iter()
332            .filter(|member| matches!(member.identity_mode, IdentityMode::Relocatable))
333            .count();
334
335        Self {
336            fixed_members,
337            relocatable_members,
338        }
339    }
340}
341
342///
343/// SnapshotProvenanceConformance
344///
345
346#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
347#[allow(clippy::struct_excessive_bools)]
348pub struct SnapshotProvenanceConformance {
349    pub design_v1_ready: bool,
350    pub member_count: usize,
351    pub members_with_snapshot_id: usize,
352    pub members_with_checksum: usize,
353    pub members_with_module_hash: usize,
354    pub members_with_wasm_hash: usize,
355    pub members_with_code_version: usize,
356    pub all_members_have_snapshot_id: bool,
357    pub all_members_have_checksum: bool,
358}
359
360impl SnapshotProvenanceConformance {
361    /// Summarize snapshot artifact provenance completeness.
362    fn from_manifest(manifest: &FleetBackupManifest) -> Self {
363        let member_count = manifest.fleet.members.len();
364        let members_with_snapshot_id = manifest
365            .fleet
366            .members
367            .iter()
368            .filter(|member| !member.source_snapshot.snapshot_id.trim().is_empty())
369            .count();
370        let members_with_checksum = manifest
371            .fleet
372            .members
373            .iter()
374            .filter(|member| member.source_snapshot.checksum.is_some())
375            .count();
376        let members_with_module_hash = manifest
377            .fleet
378            .members
379            .iter()
380            .filter(|member| member.source_snapshot.module_hash.is_some())
381            .count();
382        let members_with_wasm_hash = manifest
383            .fleet
384            .members
385            .iter()
386            .filter(|member| member.source_snapshot.wasm_hash.is_some())
387            .count();
388        let members_with_code_version = manifest
389            .fleet
390            .members
391            .iter()
392            .filter(|member| member.source_snapshot.code_version.is_some())
393            .count();
394        let all_members_have_snapshot_id = member_count == members_with_snapshot_id;
395        let all_members_have_checksum = member_count == members_with_checksum;
396        let design_v1_ready =
397            member_count > 0 && all_members_have_snapshot_id && all_members_have_checksum;
398
399        Self {
400            design_v1_ready,
401            member_count,
402            members_with_snapshot_id,
403            members_with_checksum,
404            members_with_module_hash,
405            members_with_wasm_hash,
406            members_with_code_version,
407            all_members_have_snapshot_id,
408            all_members_have_checksum,
409        }
410    }
411}
412
413///
414/// RestoreOrderConformance
415///
416
417#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
418pub struct RestoreOrderConformance {
419    pub design_v1_ready: bool,
420    pub parent_relationships: usize,
421    pub parent_group_violations: Vec<RestoreGroupViolation>,
422}
423
424impl RestoreOrderConformance {
425    /// Summarize whether restore groups preserve parent-before-child ordering.
426    fn from_manifest(manifest: &FleetBackupManifest) -> Self {
427        let members_by_id = manifest
428            .fleet
429            .members
430            .iter()
431            .map(|member| (member.canister_id.as_str(), member))
432            .collect::<BTreeMap<_, _>>();
433        let mut parent_relationships = 0;
434        let mut parent_group_violations = Vec::new();
435
436        for member in &manifest.fleet.members {
437            let Some(parent_id) = member.parent_canister_id.as_deref() else {
438                continue;
439            };
440            let Some(parent) = members_by_id.get(parent_id) else {
441                continue;
442            };
443            parent_relationships += 1;
444            if parent.restore_group > member.restore_group {
445                parent_group_violations.push(RestoreGroupViolation {
446                    parent_canister_id: parent.canister_id.clone(),
447                    child_canister_id: member.canister_id.clone(),
448                    parent_restore_group: parent.restore_group,
449                    child_restore_group: member.restore_group,
450                });
451            }
452        }
453
454        let design_v1_ready = parent_group_violations.is_empty();
455
456        Self {
457            design_v1_ready,
458            parent_relationships,
459            parent_group_violations,
460        }
461    }
462}
463
464///
465/// RestoreGroupViolation
466///
467
468#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
469pub struct RestoreGroupViolation {
470    pub parent_canister_id: String,
471    pub child_canister_id: String,
472    pub parent_restore_group: u16,
473    pub child_restore_group: u16,
474}
475
476///
477/// ToolMetadata
478///
479
480#[derive(Clone, Debug, Deserialize, Serialize)]
481pub struct ToolMetadata {
482    pub name: String,
483    pub version: String,
484}
485
486impl ToolMetadata {
487    /// Validate that the manifest names the tool that produced it.
488    pub(crate) fn validate(&self) -> Result<(), ManifestValidationError> {
489        validate_nonempty("tool.name", &self.name)?;
490        validate_nonempty("tool.version", &self.version)
491    }
492}
493
494///
495/// SourceMetadata
496///
497
498#[derive(Clone, Debug, Deserialize, Serialize)]
499pub struct SourceMetadata {
500    pub environment: String,
501    pub root_canister: String,
502}
503
504impl SourceMetadata {
505    /// Validate the source environment and root canister identity.
506    fn validate(&self) -> Result<(), ManifestValidationError> {
507        validate_nonempty("source.environment", &self.environment)?;
508        validate_principal("source.root_canister", &self.root_canister)
509    }
510}
511
512///
513/// ConsistencySection
514///
515
516#[derive(Clone, Debug, Deserialize, Serialize)]
517pub struct ConsistencySection {
518    pub mode: ConsistencyMode,
519    pub backup_units: Vec<BackupUnit>,
520}
521
522impl ConsistencySection {
523    /// Validate consistency mode and every declared backup unit.
524    fn validate(&self) -> Result<(), ManifestValidationError> {
525        if self.backup_units.is_empty() {
526            return Err(ManifestValidationError::EmptyCollection(
527                "consistency.backup_units",
528            ));
529        }
530
531        let mut unit_ids = BTreeSet::new();
532        for unit in &self.backup_units {
533            unit.validate(&self.mode)?;
534            if !unit_ids.insert(unit.unit_id.clone()) {
535                return Err(ManifestValidationError::DuplicateBackupUnitId(
536                    unit.unit_id.clone(),
537                ));
538            }
539        }
540
541        Ok(())
542    }
543}
544
545///
546/// ConsistencyMode
547///
548
549#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
550#[serde(rename_all = "kebab-case")]
551pub enum ConsistencyMode {
552    CrashConsistent,
553    QuiescedUnit,
554}
555
556///
557/// BackupUnit
558///
559
560#[derive(Clone, Debug, Deserialize, Serialize)]
561pub struct BackupUnit {
562    pub unit_id: String,
563    pub kind: BackupUnitKind,
564    pub roles: Vec<String>,
565    pub consistency_reason: Option<String>,
566    pub dependency_closure: Vec<String>,
567    pub topology_validation: String,
568    pub quiescence_strategy: Option<String>,
569}
570
571impl BackupUnit {
572    /// Validate the declared unit boundary and quiescence metadata.
573    fn validate(&self, mode: &ConsistencyMode) -> Result<(), ManifestValidationError> {
574        validate_nonempty("consistency.backup_units[].unit_id", &self.unit_id)?;
575        validate_nonempty(
576            "consistency.backup_units[].topology_validation",
577            &self.topology_validation,
578        )?;
579
580        if self.roles.is_empty() {
581            return Err(ManifestValidationError::EmptyCollection(
582                "consistency.backup_units[].roles",
583            ));
584        }
585
586        for role in &self.roles {
587            validate_nonempty("consistency.backup_units[].roles[]", role)?;
588        }
589        validate_unique_values("consistency.backup_units[].roles[]", &self.roles, |role| {
590            ManifestValidationError::DuplicateBackupUnitRole {
591                unit_id: self.unit_id.clone(),
592                role: role.to_string(),
593            }
594        })?;
595
596        for dependency in &self.dependency_closure {
597            validate_nonempty(
598                "consistency.backup_units[].dependency_closure[]",
599                dependency,
600            )?;
601        }
602        validate_unique_values(
603            "consistency.backup_units[].dependency_closure[]",
604            &self.dependency_closure,
605            |dependency| ManifestValidationError::DuplicateBackupUnitDependency {
606                unit_id: self.unit_id.clone(),
607                dependency: dependency.to_string(),
608            },
609        )?;
610
611        if matches!(self.kind, BackupUnitKind::Flat) {
612            validate_required_option(
613                "consistency.backup_units[].consistency_reason",
614                self.consistency_reason.as_deref(),
615            )?;
616        }
617
618        if matches!(mode, ConsistencyMode::QuiescedUnit) {
619            validate_required_option(
620                "consistency.backup_units[].quiescence_strategy",
621                self.quiescence_strategy.as_deref(),
622            )?;
623        }
624
625        Ok(())
626    }
627}
628
629///
630/// BackupUnitKind
631///
632
633#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
634#[serde(rename_all = "kebab-case")]
635pub enum BackupUnitKind {
636    WholeFleet,
637    ControlPlaneSubset,
638    SubtreeRooted,
639    Flat,
640}
641
642///
643/// FleetSection
644///
645
646#[derive(Clone, Debug, Deserialize, Serialize)]
647pub struct FleetSection {
648    pub topology_hash_algorithm: String,
649    pub topology_hash_input: String,
650    pub discovery_topology_hash: String,
651    pub pre_snapshot_topology_hash: String,
652    pub topology_hash: String,
653    pub members: Vec<FleetMember>,
654}
655
656impl FleetSection {
657    /// Validate topology hash invariants and member uniqueness.
658    pub(crate) fn validate(&self) -> Result<(), ManifestValidationError> {
659        validate_nonempty(
660            "fleet.topology_hash_algorithm",
661            &self.topology_hash_algorithm,
662        )?;
663        if self.topology_hash_algorithm != SHA256_ALGORITHM {
664            return Err(ManifestValidationError::UnsupportedHashAlgorithm(
665                self.topology_hash_algorithm.clone(),
666            ));
667        }
668
669        validate_nonempty("fleet.topology_hash_input", &self.topology_hash_input)?;
670        validate_hash(
671            "fleet.discovery_topology_hash",
672            &self.discovery_topology_hash,
673        )?;
674        validate_hash(
675            "fleet.pre_snapshot_topology_hash",
676            &self.pre_snapshot_topology_hash,
677        )?;
678        validate_hash("fleet.topology_hash", &self.topology_hash)?;
679
680        if self.discovery_topology_hash != self.pre_snapshot_topology_hash {
681            return Err(ManifestValidationError::TopologyHashMismatch {
682                discovery: self.discovery_topology_hash.clone(),
683                pre_snapshot: self.pre_snapshot_topology_hash.clone(),
684            });
685        }
686
687        if self.topology_hash != self.discovery_topology_hash {
688            return Err(ManifestValidationError::AcceptedTopologyHashMismatch {
689                accepted: self.topology_hash.clone(),
690                discovery: self.discovery_topology_hash.clone(),
691            });
692        }
693
694        if self.members.is_empty() {
695            return Err(ManifestValidationError::EmptyCollection("fleet.members"));
696        }
697
698        let mut canister_ids = BTreeSet::new();
699        for member in &self.members {
700            member.validate()?;
701            if !canister_ids.insert(member.canister_id.clone()) {
702                return Err(ManifestValidationError::DuplicateCanisterId(
703                    member.canister_id.clone(),
704                ));
705            }
706        }
707
708        Ok(())
709    }
710}
711
712///
713/// FleetMember
714///
715
716#[derive(Clone, Debug, Deserialize, Serialize)]
717pub struct FleetMember {
718    pub role: String,
719    pub canister_id: String,
720    pub parent_canister_id: Option<String>,
721    pub subnet_canister_id: Option<String>,
722    pub controller_hint: Option<String>,
723    pub identity_mode: IdentityMode,
724    pub restore_group: u16,
725    pub verification_class: String,
726    pub verification_checks: Vec<VerificationCheck>,
727    pub source_snapshot: SourceSnapshot,
728}
729
730impl FleetMember {
731    /// Validate one restore member projection from the manifest.
732    fn validate(&self) -> Result<(), ManifestValidationError> {
733        validate_nonempty("fleet.members[].role", &self.role)?;
734        validate_principal("fleet.members[].canister_id", &self.canister_id)?;
735        validate_optional_principal(
736            "fleet.members[].parent_canister_id",
737            self.parent_canister_id.as_deref(),
738        )?;
739        validate_optional_principal(
740            "fleet.members[].subnet_canister_id",
741            self.subnet_canister_id.as_deref(),
742        )?;
743        validate_optional_principal(
744            "fleet.members[].controller_hint",
745            self.controller_hint.as_deref(),
746        )?;
747        validate_nonempty(
748            "fleet.members[].verification_class",
749            &self.verification_class,
750        )?;
751
752        if self.verification_checks.is_empty() {
753            return Err(ManifestValidationError::MissingMemberVerificationChecks(
754                self.canister_id.clone(),
755            ));
756        }
757
758        for check in &self.verification_checks {
759            check.validate()?;
760        }
761
762        self.source_snapshot.validate()
763    }
764}
765
766///
767/// IdentityMode
768///
769
770#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
771#[serde(rename_all = "kebab-case")]
772pub enum IdentityMode {
773    Fixed,
774    Relocatable,
775}
776
777///
778/// SourceSnapshot
779///
780
781#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
782pub struct SourceSnapshot {
783    pub snapshot_id: String,
784    pub module_hash: Option<String>,
785    pub wasm_hash: Option<String>,
786    pub code_version: Option<String>,
787    pub artifact_path: String,
788    pub checksum_algorithm: String,
789    #[serde(default)]
790    pub checksum: Option<String>,
791}
792
793impl SourceSnapshot {
794    /// Validate source snapshot provenance and artifact checksum metadata.
795    fn validate(&self) -> Result<(), ManifestValidationError> {
796        validate_nonempty(
797            "fleet.members[].source_snapshot.snapshot_id",
798            &self.snapshot_id,
799        )?;
800        validate_optional_nonempty(
801            "fleet.members[].source_snapshot.module_hash",
802            self.module_hash.as_deref(),
803        )?;
804        validate_optional_nonempty(
805            "fleet.members[].source_snapshot.wasm_hash",
806            self.wasm_hash.as_deref(),
807        )?;
808        validate_optional_nonempty(
809            "fleet.members[].source_snapshot.code_version",
810            self.code_version.as_deref(),
811        )?;
812        validate_nonempty(
813            "fleet.members[].source_snapshot.artifact_path",
814            &self.artifact_path,
815        )?;
816        validate_nonempty(
817            "fleet.members[].source_snapshot.checksum_algorithm",
818            &self.checksum_algorithm,
819        )?;
820        if self.checksum_algorithm != SHA256_ALGORITHM {
821            return Err(ManifestValidationError::UnsupportedHashAlgorithm(
822                self.checksum_algorithm.clone(),
823            ));
824        }
825        validate_optional_hash(
826            "fleet.members[].source_snapshot.checksum",
827            self.checksum.as_deref(),
828        )?;
829        Ok(())
830    }
831}
832
833///
834/// VerificationPlan
835///
836
837#[derive(Clone, Debug, Default, Deserialize, Serialize)]
838pub struct VerificationPlan {
839    pub fleet_checks: Vec<VerificationCheck>,
840    pub member_checks: Vec<MemberVerificationChecks>,
841}
842
843impl VerificationPlan {
844    /// Validate all declarative verification checks.
845    fn validate(&self) -> Result<(), ManifestValidationError> {
846        for check in &self.fleet_checks {
847            check.validate()?;
848        }
849        for member in &self.member_checks {
850            member.validate()?;
851        }
852        Ok(())
853    }
854}
855
856///
857/// MemberVerificationChecks
858///
859
860#[derive(Clone, Debug, Deserialize, Serialize)]
861pub struct MemberVerificationChecks {
862    pub role: String,
863    pub checks: Vec<VerificationCheck>,
864}
865
866impl MemberVerificationChecks {
867    /// Validate one role-scoped verification check group.
868    fn validate(&self) -> Result<(), ManifestValidationError> {
869        validate_nonempty("verification.member_checks[].role", &self.role)?;
870        if self.checks.is_empty() {
871            return Err(ManifestValidationError::EmptyCollection(
872                "verification.member_checks[].checks",
873            ));
874        }
875        for check in &self.checks {
876            check.validate()?;
877        }
878        Ok(())
879    }
880}
881
882///
883/// VerificationCheck
884///
885
886#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
887pub struct VerificationCheck {
888    pub kind: String,
889    pub method: Option<String>,
890    pub roles: Vec<String>,
891}
892
893impl VerificationCheck {
894    /// Validate one concrete verification check.
895    fn validate(&self) -> Result<(), ManifestValidationError> {
896        validate_nonempty("verification.check.kind", &self.kind)?;
897        validate_optional_nonempty("verification.check.method", self.method.as_deref())?;
898        for role in &self.roles {
899            validate_nonempty("verification.check.roles[]", role)?;
900        }
901        validate_unique_values("verification.check.roles[]", &self.roles, |role| {
902            ManifestValidationError::DuplicateVerificationCheckRole {
903                kind: self.kind.clone(),
904                role: role.to_string(),
905            }
906        })?;
907        Ok(())
908    }
909}
910
911///
912/// ManifestValidationError
913///
914
915#[derive(Debug, ThisError)]
916pub enum ManifestValidationError {
917    #[error("unsupported manifest version {0}")]
918    UnsupportedManifestVersion(u16),
919
920    #[error("field {0} must not be empty")]
921    EmptyField(&'static str),
922
923    #[error("collection {0} must not be empty")]
924    EmptyCollection(&'static str),
925
926    #[error("field {field} must be a valid principal: {value}")]
927    InvalidPrincipal { field: &'static str, value: String },
928
929    #[error("field {0} must be a non-empty sha256 hex string")]
930    InvalidHash(&'static str),
931
932    #[error("unsupported hash algorithm {0}")]
933    UnsupportedHashAlgorithm(String),
934
935    #[error("topology hash mismatch between discovery {discovery} and pre-snapshot {pre_snapshot}")]
936    TopologyHashMismatch {
937        discovery: String,
938        pre_snapshot: String,
939    },
940
941    #[error("accepted topology hash {accepted} does not match discovery hash {discovery}")]
942    AcceptedTopologyHashMismatch { accepted: String, discovery: String },
943
944    #[error("duplicate canister id {0}")]
945    DuplicateCanisterId(String),
946
947    #[error("duplicate backup unit id {0}")]
948    DuplicateBackupUnitId(String),
949
950    #[error("backup unit {unit_id} repeats role {role}")]
951    DuplicateBackupUnitRole { unit_id: String, role: String },
952
953    #[error("backup unit {unit_id} repeats dependency {dependency}")]
954    DuplicateBackupUnitDependency { unit_id: String, dependency: String },
955
956    #[error("fleet member {0} has no concrete verification checks")]
957    MissingMemberVerificationChecks(String),
958
959    #[error("backup unit {unit_id} references unknown role {role}")]
960    UnknownBackupUnitRole { unit_id: String, role: String },
961
962    #[error("backup unit {unit_id} references unknown dependency {dependency}")]
963    UnknownBackupUnitDependency { unit_id: String, dependency: String },
964
965    #[error("fleet role {role} is not covered by any backup unit")]
966    BackupUnitCoverageMissingRole { role: String },
967
968    #[error("verification plan references unknown role {role}")]
969    UnknownVerificationRole { role: String },
970
971    #[error("duplicate member verification role {0}")]
972    DuplicateMemberVerificationRole(String),
973
974    #[error("verification check {kind} repeats role {role}")]
975    DuplicateVerificationCheckRole { kind: String, role: String },
976
977    #[error("whole-fleet backup unit {unit_id} omits fleet role {role}")]
978    WholeFleetUnitMissingRole { unit_id: String, role: String },
979
980    #[error("subtree backup unit {unit_id} is not connected")]
981    SubtreeBackupUnitNotConnected { unit_id: String },
982
983    #[error(
984        "subtree backup unit {unit_id} includes parent {parent} but omits descendant {descendant}"
985    )]
986    SubtreeBackupUnitMissingDescendant {
987        unit_id: String,
988        parent: String,
989        descendant: String,
990    },
991}
992
993// Return whether every fleet role is included in at least one backup unit.
994fn all_fleet_roles_covered(manifest: &FleetBackupManifest) -> bool {
995    let fleet_roles = manifest
996        .fleet
997        .members
998        .iter()
999        .map(|member| member.role.as_str())
1000        .collect::<BTreeSet<_>>();
1001    let covered_roles = manifest
1002        .consistency
1003        .backup_units
1004        .iter()
1005        .flat_map(|unit| unit.roles.iter().map(String::as_str))
1006        .collect::<BTreeSet<_>>();
1007
1008    fleet_roles.iter().all(|role| covered_roles.contains(role))
1009}
1010
1011// Validate cross-section backup unit references after local section checks pass.
1012fn validate_consistency_against_fleet(
1013    consistency: &ConsistencySection,
1014    fleet: &FleetSection,
1015) -> Result<(), ManifestValidationError> {
1016    let fleet_roles = fleet
1017        .members
1018        .iter()
1019        .map(|member| member.role.as_str())
1020        .collect::<BTreeSet<_>>();
1021    let mut covered_roles = BTreeSet::new();
1022
1023    for unit in &consistency.backup_units {
1024        for role in &unit.roles {
1025            if !fleet_roles.contains(role.as_str()) {
1026                return Err(ManifestValidationError::UnknownBackupUnitRole {
1027                    unit_id: unit.unit_id.clone(),
1028                    role: role.clone(),
1029                });
1030            }
1031            covered_roles.insert(role.as_str());
1032        }
1033
1034        for dependency in &unit.dependency_closure {
1035            if !fleet_roles.contains(dependency.as_str()) {
1036                return Err(ManifestValidationError::UnknownBackupUnitDependency {
1037                    unit_id: unit.unit_id.clone(),
1038                    dependency: dependency.clone(),
1039                });
1040            }
1041        }
1042
1043        validate_backup_unit_topology(unit, fleet, &fleet_roles)?;
1044    }
1045
1046    for role in &fleet_roles {
1047        if !covered_roles.contains(role) {
1048            return Err(ManifestValidationError::BackupUnitCoverageMissingRole {
1049                role: (*role).to_string(),
1050            });
1051        }
1052    }
1053
1054    Ok(())
1055}
1056
1057// Validate verification role references after fleet roles are known.
1058fn validate_verification_against_fleet(
1059    verification: &VerificationPlan,
1060    fleet: &FleetSection,
1061) -> Result<(), ManifestValidationError> {
1062    let fleet_roles = fleet
1063        .members
1064        .iter()
1065        .map(|member| member.role.as_str())
1066        .collect::<BTreeSet<_>>();
1067
1068    for check in &verification.fleet_checks {
1069        validate_verification_check_roles(check, &fleet_roles)?;
1070    }
1071
1072    for member in &fleet.members {
1073        for check in &member.verification_checks {
1074            validate_verification_check_roles(check, &fleet_roles)?;
1075        }
1076    }
1077
1078    let mut member_check_roles = BTreeSet::new();
1079    for member in &verification.member_checks {
1080        if !fleet_roles.contains(member.role.as_str()) {
1081            return Err(ManifestValidationError::UnknownVerificationRole {
1082                role: member.role.clone(),
1083            });
1084        }
1085        if !member_check_roles.insert(member.role.as_str()) {
1086            return Err(ManifestValidationError::DuplicateMemberVerificationRole(
1087                member.role.clone(),
1088            ));
1089        }
1090        for check in &member.checks {
1091            validate_verification_check_roles(check, &fleet_roles)?;
1092        }
1093    }
1094
1095    Ok(())
1096}
1097
1098// Validate every role filter in one verification check.
1099fn validate_verification_check_roles(
1100    check: &VerificationCheck,
1101    fleet_roles: &BTreeSet<&str>,
1102) -> Result<(), ManifestValidationError> {
1103    for role in &check.roles {
1104        if !fleet_roles.contains(role.as_str()) {
1105            return Err(ManifestValidationError::UnknownVerificationRole { role: role.clone() });
1106        }
1107    }
1108
1109    Ok(())
1110}
1111
1112// Validate backup unit topology promises against manifest membership.
1113fn validate_backup_unit_topology(
1114    unit: &BackupUnit,
1115    fleet: &FleetSection,
1116    fleet_roles: &BTreeSet<&str>,
1117) -> Result<(), ManifestValidationError> {
1118    match &unit.kind {
1119        BackupUnitKind::WholeFleet => validate_whole_fleet_unit(unit, fleet_roles),
1120        BackupUnitKind::SubtreeRooted => validate_subtree_unit(unit, fleet),
1121        BackupUnitKind::ControlPlaneSubset | BackupUnitKind::Flat => Ok(()),
1122    }
1123}
1124
1125// Ensure whole-fleet units cover every role declared by the fleet.
1126fn validate_whole_fleet_unit(
1127    unit: &BackupUnit,
1128    fleet_roles: &BTreeSet<&str>,
1129) -> Result<(), ManifestValidationError> {
1130    let unit_roles = unit
1131        .roles
1132        .iter()
1133        .map(String::as_str)
1134        .collect::<BTreeSet<_>>();
1135    for role in fleet_roles {
1136        if !unit_roles.contains(role) {
1137            return Err(ManifestValidationError::WholeFleetUnitMissingRole {
1138                unit_id: unit.unit_id.clone(),
1139                role: (*role).to_string(),
1140            });
1141        }
1142    }
1143
1144    Ok(())
1145}
1146
1147// Ensure subtree units are connected and closed under descendants.
1148fn validate_subtree_unit(
1149    unit: &BackupUnit,
1150    fleet: &FleetSection,
1151) -> Result<(), ManifestValidationError> {
1152    let unit_roles = unit
1153        .roles
1154        .iter()
1155        .map(String::as_str)
1156        .collect::<BTreeSet<_>>();
1157    let members_by_id = fleet
1158        .members
1159        .iter()
1160        .map(|member| (member.canister_id.as_str(), member))
1161        .collect::<BTreeMap<_, _>>();
1162    let unit_member_ids = fleet
1163        .members
1164        .iter()
1165        .filter(|member| unit_roles.contains(member.role.as_str()))
1166        .map(|member| member.canister_id.as_str())
1167        .collect::<BTreeSet<_>>();
1168
1169    let root_count = fleet
1170        .members
1171        .iter()
1172        .filter(|member| unit_member_ids.contains(member.canister_id.as_str()))
1173        .filter(|member| {
1174            member
1175                .parent_canister_id
1176                .as_deref()
1177                .is_none_or(|parent| !unit_member_ids.contains(parent))
1178        })
1179        .count();
1180    if root_count != 1 {
1181        return Err(ManifestValidationError::SubtreeBackupUnitNotConnected {
1182            unit_id: unit.unit_id.clone(),
1183        });
1184    }
1185
1186    for member in &fleet.members {
1187        if unit_member_ids.contains(member.canister_id.as_str()) {
1188            continue;
1189        }
1190
1191        if let Some(parent) = first_unit_ancestor(member, &members_by_id, &unit_member_ids) {
1192            return Err(
1193                ManifestValidationError::SubtreeBackupUnitMissingDescendant {
1194                    unit_id: unit.unit_id.clone(),
1195                    parent: parent.to_string(),
1196                    descendant: member.canister_id.clone(),
1197                },
1198            );
1199        }
1200    }
1201
1202    Ok(())
1203}
1204
1205// Return the nearest selected ancestor for a member outside a subtree unit.
1206fn first_unit_ancestor<'a>(
1207    member: &'a FleetMember,
1208    members_by_id: &BTreeMap<&'a str, &'a FleetMember>,
1209    unit_member_ids: &BTreeSet<&'a str>,
1210) -> Option<&'a str> {
1211    let mut visited = BTreeSet::new();
1212    let mut parent = member.parent_canister_id.as_deref();
1213    while let Some(parent_id) = parent {
1214        if unit_member_ids.contains(parent_id) {
1215            return Some(parent_id);
1216        }
1217        if !visited.insert(parent_id) {
1218            return None;
1219        }
1220        parent = members_by_id
1221            .get(parent_id)
1222            .and_then(|ancestor| ancestor.parent_canister_id.as_deref());
1223    }
1224
1225    None
1226}
1227
1228// Validate the manifest format version before checking nested fields.
1229const fn validate_manifest_version(version: u16) -> Result<(), ManifestValidationError> {
1230    if version == SUPPORTED_MANIFEST_VERSION {
1231        Ok(())
1232    } else {
1233        Err(ManifestValidationError::UnsupportedManifestVersion(version))
1234    }
1235}
1236
1237// Validate required string fields after trimming whitespace.
1238fn validate_nonempty(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
1239    if value.trim().is_empty() {
1240        Err(ManifestValidationError::EmptyField(field))
1241    } else {
1242        Ok(())
1243    }
1244}
1245
1246// Validate optional string fields only when present.
1247fn validate_optional_nonempty(
1248    field: &'static str,
1249    value: Option<&str>,
1250) -> Result<(), ManifestValidationError> {
1251    if let Some(value) = value {
1252        validate_nonempty(field, value)?;
1253    }
1254    Ok(())
1255}
1256
1257// Validate required string fields that are represented as optional manifest fields.
1258fn validate_required_option(
1259    field: &'static str,
1260    value: Option<&str>,
1261) -> Result<(), ManifestValidationError> {
1262    match value {
1263        Some(value) => validate_nonempty(field, value),
1264        None => Err(ManifestValidationError::EmptyField(field)),
1265    }
1266}
1267
1268// Validate that a string list does not repeat values.
1269fn validate_unique_values<F>(
1270    field: &'static str,
1271    values: &[String],
1272    error: F,
1273) -> Result<(), ManifestValidationError>
1274where
1275    F: Fn(&str) -> ManifestValidationError,
1276{
1277    let mut seen = BTreeSet::new();
1278    for value in values {
1279        validate_nonempty(field, value)?;
1280        if !seen.insert(value.as_str()) {
1281            return Err(error(value));
1282        }
1283    }
1284
1285    Ok(())
1286}
1287
1288// Validate textual principal fields used in JSON manifests.
1289fn validate_principal(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
1290    validate_nonempty(field, value)?;
1291    Principal::from_str(value)
1292        .map(|_| ())
1293        .map_err(|_| ManifestValidationError::InvalidPrincipal {
1294            field,
1295            value: value.to_string(),
1296        })
1297}
1298
1299// Validate optional textual principal fields used in JSON manifests.
1300fn validate_optional_principal(
1301    field: &'static str,
1302    value: Option<&str>,
1303) -> Result<(), ManifestValidationError> {
1304    if let Some(value) = value {
1305        validate_principal(field, value)?;
1306    }
1307    Ok(())
1308}
1309
1310// Validate SHA-256 hex values used for topology and artifact compatibility.
1311fn validate_hash(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
1312    const SHA256_HEX_LEN: usize = 64;
1313    validate_nonempty(field, value)?;
1314    if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
1315        Ok(())
1316    } else {
1317        Err(ManifestValidationError::InvalidHash(field))
1318    }
1319}
1320
1321// Validate optional SHA-256 hex values only when present.
1322fn validate_optional_hash(
1323    field: &'static str,
1324    value: Option<&str>,
1325) -> Result<(), ManifestValidationError> {
1326    if let Some(value) = value {
1327        validate_hash(field, value)?;
1328    }
1329    Ok(())
1330}
1331
1332#[cfg(test)]
1333mod tests {
1334    use super::*;
1335
1336    const ROOT: &str = "aaaaa-aa";
1337    const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
1338    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1339
1340    // Build one valid manifest for validation tests.
1341    fn valid_manifest() -> FleetBackupManifest {
1342        FleetBackupManifest {
1343            manifest_version: 1,
1344            backup_id: "fbk_test_001".to_string(),
1345            created_at: "2026-04-10T12:00:00Z".to_string(),
1346            tool: ToolMetadata {
1347                name: "canic".to_string(),
1348                version: "v1".to_string(),
1349            },
1350            source: SourceMetadata {
1351                environment: "local".to_string(),
1352                root_canister: ROOT.to_string(),
1353            },
1354            consistency: ConsistencySection {
1355                mode: ConsistencyMode::QuiescedUnit,
1356                backup_units: vec![BackupUnit {
1357                    unit_id: "core".to_string(),
1358                    kind: BackupUnitKind::Flat,
1359                    roles: vec!["root".to_string(), "app".to_string()],
1360                    consistency_reason: Some("root and app state are coordinated".to_string()),
1361                    dependency_closure: vec!["root".to_string(), "app".to_string()],
1362                    topology_validation: "operator-declared-flat".to_string(),
1363                    quiescence_strategy: Some("standard-canic-hooks@v1".to_string()),
1364                }],
1365            },
1366            fleet: FleetSection {
1367                topology_hash_algorithm: "sha256".to_string(),
1368                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1369                discovery_topology_hash: HASH.to_string(),
1370                pre_snapshot_topology_hash: HASH.to_string(),
1371                topology_hash: HASH.to_string(),
1372                members: vec![
1373                    fleet_member("root", ROOT, None, IdentityMode::Fixed),
1374                    fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
1375                ],
1376            },
1377            verification: VerificationPlan {
1378                fleet_checks: vec![VerificationCheck {
1379                    kind: "root_ready".to_string(),
1380                    method: None,
1381                    roles: Vec::new(),
1382                }],
1383                member_checks: Vec::new(),
1384            },
1385        }
1386    }
1387
1388    #[test]
1389    fn valid_manifest_passes_validation() {
1390        let manifest = valid_manifest();
1391
1392        manifest.validate().expect("manifest should validate");
1393    }
1394
1395    // Ensure snapshot checksum provenance stays canonical when present.
1396    #[test]
1397    fn invalid_snapshot_checksum_fails_validation() {
1398        let mut manifest = valid_manifest();
1399        manifest.fleet.members[0].source_snapshot.checksum = Some("not-a-sha".to_string());
1400
1401        let err = manifest
1402            .validate()
1403            .expect_err("invalid snapshot checksum should fail");
1404
1405        assert!(matches!(
1406            err,
1407            ManifestValidationError::InvalidHash("fleet.members[].source_snapshot.checksum")
1408        ));
1409    }
1410
1411    // Build one valid fleet member for manifest validation tests.
1412    fn fleet_member(
1413        role: &str,
1414        canister_id: &str,
1415        parent_canister_id: Option<&str>,
1416        identity_mode: IdentityMode,
1417    ) -> FleetMember {
1418        FleetMember {
1419            role: role.to_string(),
1420            canister_id: canister_id.to_string(),
1421            parent_canister_id: parent_canister_id.map(str::to_string),
1422            subnet_canister_id: Some(CHILD.to_string()),
1423            controller_hint: Some(ROOT.to_string()),
1424            identity_mode,
1425            restore_group: 1,
1426            verification_class: "basic".to_string(),
1427            verification_checks: vec![VerificationCheck {
1428                kind: "call".to_string(),
1429                method: Some("canic_ready".to_string()),
1430                roles: Vec::new(),
1431            }],
1432            source_snapshot: SourceSnapshot {
1433                snapshot_id: format!("snap-{role}"),
1434                module_hash: Some(HASH.to_string()),
1435                wasm_hash: Some(HASH.to_string()),
1436                code_version: Some("v0.30.0".to_string()),
1437                artifact_path: format!("artifacts/{role}"),
1438                checksum_algorithm: "sha256".to_string(),
1439                checksum: Some(HASH.to_string()),
1440            },
1441        }
1442    }
1443
1444    #[test]
1445    fn topology_hash_mismatch_fails_validation() {
1446        let mut manifest = valid_manifest();
1447        manifest.fleet.pre_snapshot_topology_hash =
1448            "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string();
1449
1450        let err = manifest.validate().expect_err("mismatch should fail");
1451
1452        assert!(matches!(
1453            err,
1454            ManifestValidationError::TopologyHashMismatch { .. }
1455        ));
1456    }
1457
1458    #[test]
1459    fn missing_member_verification_checks_fail_validation() {
1460        let mut manifest = valid_manifest();
1461        manifest.fleet.members[0].verification_checks.clear();
1462
1463        let err = manifest
1464            .validate()
1465            .expect_err("missing member checks should fail");
1466
1467        assert!(matches!(
1468            err,
1469            ManifestValidationError::MissingMemberVerificationChecks(_)
1470        ));
1471    }
1472
1473    #[test]
1474    fn quiesced_unit_requires_quiescence_strategy() {
1475        let mut manifest = valid_manifest();
1476        manifest.consistency.backup_units[0].quiescence_strategy = None;
1477
1478        let err = manifest
1479            .validate()
1480            .expect_err("missing quiescence strategy should fail");
1481
1482        assert!(matches!(err, ManifestValidationError::EmptyField(_)));
1483    }
1484
1485    #[test]
1486    fn backup_unit_roles_must_exist_in_fleet() {
1487        let mut manifest = valid_manifest();
1488        manifest.consistency.backup_units[0]
1489            .roles
1490            .push("missing-role".to_string());
1491
1492        let err = manifest
1493            .validate()
1494            .expect_err("unknown backup unit role should fail");
1495
1496        assert!(matches!(
1497            err,
1498            ManifestValidationError::UnknownBackupUnitRole { .. }
1499        ));
1500    }
1501
1502    #[test]
1503    fn backup_unit_dependencies_must_exist_in_fleet() {
1504        let mut manifest = valid_manifest();
1505        manifest.consistency.backup_units[0]
1506            .dependency_closure
1507            .push("missing-dependency".to_string());
1508
1509        let err = manifest
1510            .validate()
1511            .expect_err("unknown backup unit dependency should fail");
1512
1513        assert!(matches!(
1514            err,
1515            ManifestValidationError::UnknownBackupUnitDependency { .. }
1516        ));
1517    }
1518
1519    #[test]
1520    fn backup_unit_ids_must_be_unique() {
1521        let mut manifest = valid_manifest();
1522        manifest
1523            .consistency
1524            .backup_units
1525            .push(manifest.consistency.backup_units[0].clone());
1526
1527        let err = manifest
1528            .validate()
1529            .expect_err("duplicate unit IDs should fail");
1530
1531        assert!(matches!(
1532            err,
1533            ManifestValidationError::DuplicateBackupUnitId(_)
1534        ));
1535    }
1536
1537    #[test]
1538    fn backup_unit_roles_must_be_unique() {
1539        let mut manifest = valid_manifest();
1540        manifest.consistency.backup_units[0]
1541            .roles
1542            .push("root".to_string());
1543
1544        let err = manifest
1545            .validate()
1546            .expect_err("duplicate backup unit role should fail");
1547
1548        assert!(matches!(
1549            err,
1550            ManifestValidationError::DuplicateBackupUnitRole { .. }
1551        ));
1552    }
1553
1554    #[test]
1555    fn backup_unit_dependencies_must_be_unique() {
1556        let mut manifest = valid_manifest();
1557        manifest.consistency.backup_units[0]
1558            .dependency_closure
1559            .push("root".to_string());
1560
1561        let err = manifest
1562            .validate()
1563            .expect_err("duplicate backup unit dependency should fail");
1564
1565        assert!(matches!(
1566            err,
1567            ManifestValidationError::DuplicateBackupUnitDependency { .. }
1568        ));
1569    }
1570
1571    #[test]
1572    fn every_fleet_role_must_be_covered_by_a_backup_unit() {
1573        let mut manifest = valid_manifest();
1574        manifest.consistency.backup_units[0].roles = vec!["root".to_string()];
1575        manifest.consistency.backup_units[0].dependency_closure = vec!["root".to_string()];
1576
1577        let err = manifest
1578            .validate()
1579            .expect_err("uncovered app role should fail");
1580
1581        assert!(matches!(
1582            err,
1583            ManifestValidationError::BackupUnitCoverageMissingRole { .. }
1584        ));
1585    }
1586
1587    #[test]
1588    fn fleet_verification_roles_must_exist_in_fleet() {
1589        let mut manifest = valid_manifest();
1590        manifest.verification.fleet_checks[0]
1591            .roles
1592            .push("missing-role".to_string());
1593
1594        let err = manifest
1595            .validate()
1596            .expect_err("unknown fleet verification role should fail");
1597
1598        assert!(matches!(
1599            err,
1600            ManifestValidationError::UnknownVerificationRole { .. }
1601        ));
1602    }
1603
1604    #[test]
1605    fn member_verification_check_roles_must_exist_in_fleet() {
1606        let mut manifest = valid_manifest();
1607        manifest.fleet.members[0].verification_checks[0]
1608            .roles
1609            .push("missing-role".to_string());
1610
1611        let err = manifest
1612            .validate()
1613            .expect_err("unknown member verification check role should fail");
1614
1615        assert!(matches!(
1616            err,
1617            ManifestValidationError::UnknownVerificationRole { .. }
1618        ));
1619    }
1620
1621    #[test]
1622    fn verification_check_roles_must_be_unique() {
1623        let mut manifest = valid_manifest();
1624        manifest.verification.fleet_checks[0]
1625            .roles
1626            .push("root".to_string());
1627        manifest.verification.fleet_checks[0]
1628            .roles
1629            .push("root".to_string());
1630
1631        let err = manifest
1632            .validate()
1633            .expect_err("duplicate verification role filter should fail");
1634
1635        assert!(matches!(
1636            err,
1637            ManifestValidationError::DuplicateVerificationCheckRole { .. }
1638        ));
1639    }
1640
1641    #[test]
1642    fn member_verification_group_roles_must_exist_in_fleet() {
1643        let mut manifest = valid_manifest();
1644        manifest
1645            .verification
1646            .member_checks
1647            .push(MemberVerificationChecks {
1648                role: "missing-role".to_string(),
1649                checks: vec![VerificationCheck {
1650                    kind: "ready".to_string(),
1651                    method: None,
1652                    roles: Vec::new(),
1653                }],
1654            });
1655
1656        let err = manifest
1657            .validate()
1658            .expect_err("unknown member verification role should fail");
1659
1660        assert!(matches!(
1661            err,
1662            ManifestValidationError::UnknownVerificationRole { .. }
1663        ));
1664    }
1665
1666    #[test]
1667    fn member_verification_group_roles_must_be_unique() {
1668        let mut manifest = valid_manifest();
1669        manifest
1670            .verification
1671            .member_checks
1672            .push(member_verification_checks("root"));
1673        manifest
1674            .verification
1675            .member_checks
1676            .push(member_verification_checks("root"));
1677
1678        let err = manifest
1679            .validate()
1680            .expect_err("duplicate member verification role should fail");
1681
1682        assert!(matches!(
1683            err,
1684            ManifestValidationError::DuplicateMemberVerificationRole(_)
1685        ));
1686    }
1687
1688    #[test]
1689    fn nested_member_verification_roles_must_exist_in_fleet() {
1690        let mut manifest = valid_manifest();
1691        let mut checks = member_verification_checks("root");
1692        checks.checks[0].roles.push("missing-role".to_string());
1693        manifest.verification.member_checks.push(checks);
1694
1695        let err = manifest
1696            .validate()
1697            .expect_err("unknown nested verification role should fail");
1698
1699        assert!(matches!(
1700            err,
1701            ManifestValidationError::UnknownVerificationRole { .. }
1702        ));
1703    }
1704
1705    #[test]
1706    fn whole_fleet_unit_must_cover_all_roles() {
1707        let mut manifest = valid_manifest();
1708        manifest.consistency.backup_units[0].kind = BackupUnitKind::WholeFleet;
1709        manifest.consistency.backup_units[0].roles = vec!["root".to_string()];
1710        manifest.consistency.backup_units[0].consistency_reason = None;
1711
1712        let err = manifest
1713            .validate()
1714            .expect_err("whole-fleet unit missing app role should fail");
1715
1716        assert!(matches!(
1717            err,
1718            ManifestValidationError::WholeFleetUnitMissingRole { .. }
1719        ));
1720    }
1721
1722    #[test]
1723    fn subtree_unit_must_be_closed_under_descendants() {
1724        let mut manifest = valid_manifest();
1725        manifest.consistency.backup_units[0].kind = BackupUnitKind::SubtreeRooted;
1726        manifest.consistency.backup_units[0].roles = vec!["root".to_string()];
1727        manifest.consistency.backup_units[0].consistency_reason = None;
1728
1729        let err = manifest
1730            .validate()
1731            .expect_err("subtree unit omitting app child should fail");
1732
1733        assert!(matches!(
1734            err,
1735            ManifestValidationError::SubtreeBackupUnitMissingDescendant { .. }
1736        ));
1737    }
1738
1739    #[test]
1740    fn subtree_unit_must_be_connected() {
1741        let mut manifest = valid_manifest();
1742        manifest.fleet.members.push(fleet_member(
1743            "worker",
1744            "r7inp-6aaaa-aaaaa-aaabq-cai",
1745            None,
1746            IdentityMode::Relocatable,
1747        ));
1748        manifest.consistency.backup_units[0].kind = BackupUnitKind::SubtreeRooted;
1749        manifest.consistency.backup_units[0].roles = vec!["app".to_string(), "worker".to_string()];
1750        manifest.consistency.backup_units[0].consistency_reason = None;
1751        manifest.consistency.backup_units[0]
1752            .dependency_closure
1753            .push("worker".to_string());
1754
1755        let err = manifest
1756            .validate()
1757            .expect_err("disconnected subtree unit should fail");
1758
1759        assert!(matches!(
1760            err,
1761            ManifestValidationError::SubtreeBackupUnitNotConnected { .. }
1762        ));
1763    }
1764
1765    #[test]
1766    fn design_conformance_report_accepts_ready_manifest() {
1767        let manifest = valid_manifest();
1768
1769        let report = manifest.design_conformance_report();
1770
1771        assert!(report.design_v1_ready);
1772        assert_eq!(report.design_version, DESIGN_V1);
1773        assert!(report.topology.design_v1_ready);
1774        assert!(report.topology.canonical_input);
1775        assert!(report.backup_units.design_v1_ready);
1776        assert_eq!(report.backup_units.flat_units, 1);
1777        assert_eq!(report.backup_units.flat_units_with_reason, 1);
1778        assert!(report.quiescence.design_v1_ready);
1779        assert!(report.quiescence.quiescence_required);
1780        assert_eq!(report.verification.members_with_checks, 2);
1781        assert_eq!(report.identity.fixed_members, 1);
1782        assert_eq!(report.identity.relocatable_members, 1);
1783        assert!(report.snapshot_provenance.all_members_have_checksum);
1784        assert!(report.restore_order.design_v1_ready);
1785    }
1786
1787    #[test]
1788    fn design_conformance_report_flags_soft_gaps() {
1789        let mut manifest = valid_manifest();
1790        manifest.fleet.topology_hash_input = "legacy-input".to_string();
1791        manifest.fleet.members[0].source_snapshot.checksum = None;
1792        manifest.fleet.members[0].restore_group = 2;
1793        manifest.fleet.members[1].restore_group = 1;
1794
1795        let report = manifest.design_conformance_report();
1796
1797        assert!(!report.design_v1_ready);
1798        assert!(!report.topology.canonical_input);
1799        assert!(!report.snapshot_provenance.all_members_have_checksum);
1800        assert_eq!(report.restore_order.parent_group_violations.len(), 1);
1801        assert_eq!(
1802            report.restore_order.parent_group_violations[0].parent_canister_id,
1803            ROOT
1804        );
1805    }
1806
1807    #[test]
1808    fn manifest_round_trips_through_json() {
1809        let manifest = valid_manifest();
1810
1811        let encoded = serde_json::to_string(&manifest).expect("serialize manifest");
1812        let decoded: FleetBackupManifest =
1813            serde_json::from_str(&encoded).expect("deserialize manifest");
1814
1815        decoded
1816            .validate()
1817            .expect("decoded manifest should validate");
1818    }
1819
1820    // Build one role-scoped verification group for validation tests.
1821    fn member_verification_checks(role: &str) -> MemberVerificationChecks {
1822        MemberVerificationChecks {
1823            role: role.to_string(),
1824            checks: vec![VerificationCheck {
1825                kind: "ready".to_string(),
1826                method: None,
1827                roles: Vec::new(),
1828            }],
1829        }
1830    }
1831}