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