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#[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 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 #[must_use]
48 pub fn design_conformance_report(&self) -> ManifestDesignConformanceReport {
49 ManifestDesignConformanceReport::from_manifest(self)
50 }
51}
52
53#[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 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#[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 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#[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 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#[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 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#[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 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#[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 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#[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 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#[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 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#[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#[derive(Clone, Debug, Deserialize, Serialize)]
481pub struct ToolMetadata {
482 pub name: String,
483 pub version: String,
484}
485
486impl ToolMetadata {
487 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#[derive(Clone, Debug, Deserialize, Serialize)]
499pub struct SourceMetadata {
500 pub environment: String,
501 pub root_canister: String,
502}
503
504impl SourceMetadata {
505 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#[derive(Clone, Debug, Deserialize, Serialize)]
517pub struct ConsistencySection {
518 pub mode: ConsistencyMode,
519 pub backup_units: Vec<BackupUnit>,
520}
521
522impl ConsistencySection {
523 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
550#[serde(rename_all = "kebab-case")]
551pub enum ConsistencyMode {
552 CrashConsistent,
553 QuiescedUnit,
554}
555
556#[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 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#[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#[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 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#[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 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
771#[serde(rename_all = "kebab-case")]
772pub enum IdentityMode {
773 Fixed,
774 Relocatable,
775}
776
777#[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 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#[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 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#[derive(Clone, Debug, Deserialize, Serialize)]
861pub struct MemberVerificationChecks {
862 pub role: String,
863 pub checks: Vec<VerificationCheck>,
864}
865
866impl MemberVerificationChecks {
867 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#[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 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#[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
993fn 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
1011fn 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
1057fn 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
1098fn 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
1112fn 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
1125fn 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
1147fn 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
1205fn 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
1228const 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
1237fn 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
1246fn 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
1257fn 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
1268fn 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
1288fn 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
1299fn 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
1310fn 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
1321fn 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 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 #[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 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 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}