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";
11
12#[derive(Clone, Debug, Deserialize, Serialize)]
17pub struct FleetBackupManifest {
18 pub manifest_version: u16,
19 pub backup_id: String,
20 pub created_at: String,
21 pub tool: ToolMetadata,
22 pub source: SourceMetadata,
23 pub consistency: ConsistencySection,
24 pub fleet: FleetSection,
25 pub verification: VerificationPlan,
26}
27
28impl FleetBackupManifest {
29 pub fn validate(&self) -> Result<(), ManifestValidationError> {
31 validate_manifest_version(self.manifest_version)?;
32 validate_nonempty("backup_id", &self.backup_id)?;
33 validate_nonempty("created_at", &self.created_at)?;
34 self.tool.validate()?;
35 self.source.validate()?;
36 self.consistency.validate()?;
37 self.fleet.validate()?;
38 self.verification.validate()?;
39 validate_consistency_against_fleet(&self.consistency, &self.fleet)?;
40 validate_verification_against_fleet(&self.verification, &self.fleet)?;
41 Ok(())
42 }
43}
44
45#[derive(Clone, Debug, Deserialize, Serialize)]
50pub struct ToolMetadata {
51 pub name: String,
52 pub version: String,
53}
54
55impl ToolMetadata {
56 pub(crate) fn validate(&self) -> Result<(), ManifestValidationError> {
58 validate_nonempty("tool.name", &self.name)?;
59 validate_nonempty("tool.version", &self.version)
60 }
61}
62
63#[derive(Clone, Debug, Deserialize, Serialize)]
68pub struct SourceMetadata {
69 pub environment: String,
70 pub root_canister: String,
71}
72
73impl SourceMetadata {
74 fn validate(&self) -> Result<(), ManifestValidationError> {
76 validate_nonempty("source.environment", &self.environment)?;
77 validate_principal("source.root_canister", &self.root_canister)
78 }
79}
80
81#[derive(Clone, Debug, Deserialize, Serialize)]
86pub struct ConsistencySection {
87 pub mode: ConsistencyMode,
88 pub backup_units: Vec<BackupUnit>,
89}
90
91impl ConsistencySection {
92 fn validate(&self) -> Result<(), ManifestValidationError> {
94 if self.backup_units.is_empty() {
95 return Err(ManifestValidationError::EmptyCollection(
96 "consistency.backup_units",
97 ));
98 }
99
100 let mut unit_ids = BTreeSet::new();
101 for unit in &self.backup_units {
102 unit.validate(&self.mode)?;
103 if !unit_ids.insert(unit.unit_id.clone()) {
104 return Err(ManifestValidationError::DuplicateBackupUnitId(
105 unit.unit_id.clone(),
106 ));
107 }
108 }
109
110 Ok(())
111 }
112}
113
114#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
119#[serde(rename_all = "kebab-case")]
120pub enum ConsistencyMode {
121 CrashConsistent,
122 QuiescedUnit,
123}
124
125#[derive(Clone, Debug, Deserialize, Serialize)]
130pub struct BackupUnit {
131 pub unit_id: String,
132 pub kind: BackupUnitKind,
133 pub roles: Vec<String>,
134 pub consistency_reason: Option<String>,
135 pub dependency_closure: Vec<String>,
136 pub topology_validation: String,
137 pub quiescence_strategy: Option<String>,
138}
139
140impl BackupUnit {
141 fn validate(&self, mode: &ConsistencyMode) -> Result<(), ManifestValidationError> {
143 validate_nonempty("consistency.backup_units[].unit_id", &self.unit_id)?;
144 validate_nonempty(
145 "consistency.backup_units[].topology_validation",
146 &self.topology_validation,
147 )?;
148
149 if self.roles.is_empty() {
150 return Err(ManifestValidationError::EmptyCollection(
151 "consistency.backup_units[].roles",
152 ));
153 }
154
155 for role in &self.roles {
156 validate_nonempty("consistency.backup_units[].roles[]", role)?;
157 }
158 validate_unique_values("consistency.backup_units[].roles[]", &self.roles, |role| {
159 ManifestValidationError::DuplicateBackupUnitRole {
160 unit_id: self.unit_id.clone(),
161 role: role.to_string(),
162 }
163 })?;
164
165 for dependency in &self.dependency_closure {
166 validate_nonempty(
167 "consistency.backup_units[].dependency_closure[]",
168 dependency,
169 )?;
170 }
171 validate_unique_values(
172 "consistency.backup_units[].dependency_closure[]",
173 &self.dependency_closure,
174 |dependency| ManifestValidationError::DuplicateBackupUnitDependency {
175 unit_id: self.unit_id.clone(),
176 dependency: dependency.to_string(),
177 },
178 )?;
179
180 if matches!(self.kind, BackupUnitKind::Flat) {
181 validate_required_option(
182 "consistency.backup_units[].consistency_reason",
183 self.consistency_reason.as_deref(),
184 )?;
185 }
186
187 if matches!(mode, ConsistencyMode::QuiescedUnit) {
188 validate_required_option(
189 "consistency.backup_units[].quiescence_strategy",
190 self.quiescence_strategy.as_deref(),
191 )?;
192 }
193
194 Ok(())
195 }
196}
197
198#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
203#[serde(rename_all = "kebab-case")]
204pub enum BackupUnitKind {
205 WholeFleet,
206 ControlPlaneSubset,
207 SubtreeRooted,
208 Flat,
209}
210
211#[derive(Clone, Debug, Deserialize, Serialize)]
216pub struct FleetSection {
217 pub topology_hash_algorithm: String,
218 pub topology_hash_input: String,
219 pub discovery_topology_hash: String,
220 pub pre_snapshot_topology_hash: String,
221 pub topology_hash: String,
222 pub members: Vec<FleetMember>,
223}
224
225impl FleetSection {
226 pub(crate) fn validate(&self) -> Result<(), ManifestValidationError> {
228 validate_nonempty(
229 "fleet.topology_hash_algorithm",
230 &self.topology_hash_algorithm,
231 )?;
232 if self.topology_hash_algorithm != SHA256_ALGORITHM {
233 return Err(ManifestValidationError::UnsupportedHashAlgorithm(
234 self.topology_hash_algorithm.clone(),
235 ));
236 }
237
238 validate_nonempty("fleet.topology_hash_input", &self.topology_hash_input)?;
239 validate_hash(
240 "fleet.discovery_topology_hash",
241 &self.discovery_topology_hash,
242 )?;
243 validate_hash(
244 "fleet.pre_snapshot_topology_hash",
245 &self.pre_snapshot_topology_hash,
246 )?;
247 validate_hash("fleet.topology_hash", &self.topology_hash)?;
248
249 if self.discovery_topology_hash != self.pre_snapshot_topology_hash {
250 return Err(ManifestValidationError::TopologyHashMismatch {
251 discovery: self.discovery_topology_hash.clone(),
252 pre_snapshot: self.pre_snapshot_topology_hash.clone(),
253 });
254 }
255
256 if self.topology_hash != self.discovery_topology_hash {
257 return Err(ManifestValidationError::AcceptedTopologyHashMismatch {
258 accepted: self.topology_hash.clone(),
259 discovery: self.discovery_topology_hash.clone(),
260 });
261 }
262
263 if self.members.is_empty() {
264 return Err(ManifestValidationError::EmptyCollection("fleet.members"));
265 }
266
267 let mut canister_ids = BTreeSet::new();
268 for member in &self.members {
269 member.validate()?;
270 if !canister_ids.insert(member.canister_id.clone()) {
271 return Err(ManifestValidationError::DuplicateCanisterId(
272 member.canister_id.clone(),
273 ));
274 }
275 }
276
277 Ok(())
278 }
279}
280
281#[derive(Clone, Debug, Deserialize, Serialize)]
286pub struct FleetMember {
287 pub role: String,
288 pub canister_id: String,
289 pub parent_canister_id: Option<String>,
290 pub subnet_canister_id: Option<String>,
291 pub controller_hint: Option<String>,
292 pub identity_mode: IdentityMode,
293 pub restore_group: u16,
294 pub verification_class: String,
295 pub verification_checks: Vec<VerificationCheck>,
296 pub source_snapshot: SourceSnapshot,
297}
298
299impl FleetMember {
300 fn validate(&self) -> Result<(), ManifestValidationError> {
302 validate_nonempty("fleet.members[].role", &self.role)?;
303 validate_principal("fleet.members[].canister_id", &self.canister_id)?;
304 validate_optional_principal(
305 "fleet.members[].parent_canister_id",
306 self.parent_canister_id.as_deref(),
307 )?;
308 validate_optional_principal(
309 "fleet.members[].subnet_canister_id",
310 self.subnet_canister_id.as_deref(),
311 )?;
312 validate_optional_principal(
313 "fleet.members[].controller_hint",
314 self.controller_hint.as_deref(),
315 )?;
316 validate_nonempty(
317 "fleet.members[].verification_class",
318 &self.verification_class,
319 )?;
320
321 if self.verification_checks.is_empty() {
322 return Err(ManifestValidationError::MissingMemberVerificationChecks(
323 self.canister_id.clone(),
324 ));
325 }
326
327 for check in &self.verification_checks {
328 check.validate()?;
329 }
330
331 self.source_snapshot.validate()
332 }
333}
334
335#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
340#[serde(rename_all = "kebab-case")]
341pub enum IdentityMode {
342 Fixed,
343 Relocatable,
344}
345
346#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
351pub struct SourceSnapshot {
352 pub snapshot_id: String,
353 pub module_hash: Option<String>,
354 pub wasm_hash: Option<String>,
355 pub code_version: Option<String>,
356 pub artifact_path: String,
357 pub checksum_algorithm: String,
358 #[serde(default)]
359 pub checksum: Option<String>,
360}
361
362impl SourceSnapshot {
363 fn validate(&self) -> Result<(), ManifestValidationError> {
365 validate_nonempty(
366 "fleet.members[].source_snapshot.snapshot_id",
367 &self.snapshot_id,
368 )?;
369 validate_optional_nonempty(
370 "fleet.members[].source_snapshot.module_hash",
371 self.module_hash.as_deref(),
372 )?;
373 validate_optional_nonempty(
374 "fleet.members[].source_snapshot.wasm_hash",
375 self.wasm_hash.as_deref(),
376 )?;
377 validate_optional_nonempty(
378 "fleet.members[].source_snapshot.code_version",
379 self.code_version.as_deref(),
380 )?;
381 validate_nonempty(
382 "fleet.members[].source_snapshot.artifact_path",
383 &self.artifact_path,
384 )?;
385 validate_nonempty(
386 "fleet.members[].source_snapshot.checksum_algorithm",
387 &self.checksum_algorithm,
388 )?;
389 if self.checksum_algorithm != SHA256_ALGORITHM {
390 return Err(ManifestValidationError::UnsupportedHashAlgorithm(
391 self.checksum_algorithm.clone(),
392 ));
393 }
394 validate_optional_hash(
395 "fleet.members[].source_snapshot.checksum",
396 self.checksum.as_deref(),
397 )?;
398 Ok(())
399 }
400}
401
402#[derive(Clone, Debug, Default, Deserialize, Serialize)]
407pub struct VerificationPlan {
408 pub fleet_checks: Vec<VerificationCheck>,
409 pub member_checks: Vec<MemberVerificationChecks>,
410}
411
412impl VerificationPlan {
413 fn validate(&self) -> Result<(), ManifestValidationError> {
415 for check in &self.fleet_checks {
416 check.validate()?;
417 }
418 for member in &self.member_checks {
419 member.validate()?;
420 }
421 Ok(())
422 }
423}
424
425#[derive(Clone, Debug, Deserialize, Serialize)]
430pub struct MemberVerificationChecks {
431 pub role: String,
432 pub checks: Vec<VerificationCheck>,
433}
434
435impl MemberVerificationChecks {
436 fn validate(&self) -> Result<(), ManifestValidationError> {
438 validate_nonempty("verification.member_checks[].role", &self.role)?;
439 if self.checks.is_empty() {
440 return Err(ManifestValidationError::EmptyCollection(
441 "verification.member_checks[].checks",
442 ));
443 }
444 for check in &self.checks {
445 check.validate()?;
446 }
447 Ok(())
448 }
449}
450
451#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
456pub struct VerificationCheck {
457 pub kind: String,
458 pub method: Option<String>,
459 pub roles: Vec<String>,
460}
461
462impl VerificationCheck {
463 fn validate(&self) -> Result<(), ManifestValidationError> {
465 validate_nonempty("verification.check.kind", &self.kind)?;
466 validate_optional_nonempty("verification.check.method", self.method.as_deref())?;
467 for role in &self.roles {
468 validate_nonempty("verification.check.roles[]", role)?;
469 }
470 validate_unique_values("verification.check.roles[]", &self.roles, |role| {
471 ManifestValidationError::DuplicateVerificationCheckRole {
472 kind: self.kind.clone(),
473 role: role.to_string(),
474 }
475 })?;
476 Ok(())
477 }
478}
479
480#[derive(Debug, ThisError)]
485pub enum ManifestValidationError {
486 #[error("unsupported manifest version {0}")]
487 UnsupportedManifestVersion(u16),
488
489 #[error("field {0} must not be empty")]
490 EmptyField(&'static str),
491
492 #[error("collection {0} must not be empty")]
493 EmptyCollection(&'static str),
494
495 #[error("field {field} must be a valid principal: {value}")]
496 InvalidPrincipal { field: &'static str, value: String },
497
498 #[error("field {0} must be a non-empty sha256 hex string")]
499 InvalidHash(&'static str),
500
501 #[error("unsupported hash algorithm {0}")]
502 UnsupportedHashAlgorithm(String),
503
504 #[error("topology hash mismatch between discovery {discovery} and pre-snapshot {pre_snapshot}")]
505 TopologyHashMismatch {
506 discovery: String,
507 pre_snapshot: String,
508 },
509
510 #[error("accepted topology hash {accepted} does not match discovery hash {discovery}")]
511 AcceptedTopologyHashMismatch { accepted: String, discovery: String },
512
513 #[error("duplicate canister id {0}")]
514 DuplicateCanisterId(String),
515
516 #[error("duplicate backup unit id {0}")]
517 DuplicateBackupUnitId(String),
518
519 #[error("backup unit {unit_id} repeats role {role}")]
520 DuplicateBackupUnitRole { unit_id: String, role: String },
521
522 #[error("backup unit {unit_id} repeats dependency {dependency}")]
523 DuplicateBackupUnitDependency { unit_id: String, dependency: String },
524
525 #[error("fleet member {0} has no concrete verification checks")]
526 MissingMemberVerificationChecks(String),
527
528 #[error("backup unit {unit_id} references unknown role {role}")]
529 UnknownBackupUnitRole { unit_id: String, role: String },
530
531 #[error("backup unit {unit_id} references unknown dependency {dependency}")]
532 UnknownBackupUnitDependency { unit_id: String, dependency: String },
533
534 #[error("fleet role {role} is not covered by any backup unit")]
535 BackupUnitCoverageMissingRole { role: String },
536
537 #[error("verification plan references unknown role {role}")]
538 UnknownVerificationRole { role: String },
539
540 #[error("duplicate member verification role {0}")]
541 DuplicateMemberVerificationRole(String),
542
543 #[error("verification check {kind} repeats role {role}")]
544 DuplicateVerificationCheckRole { kind: String, role: String },
545
546 #[error("whole-fleet backup unit {unit_id} omits fleet role {role}")]
547 WholeFleetUnitMissingRole { unit_id: String, role: String },
548
549 #[error("subtree backup unit {unit_id} is not connected")]
550 SubtreeBackupUnitNotConnected { unit_id: String },
551
552 #[error(
553 "subtree backup unit {unit_id} includes parent {parent} but omits descendant {descendant}"
554 )]
555 SubtreeBackupUnitMissingDescendant {
556 unit_id: String,
557 parent: String,
558 descendant: String,
559 },
560}
561
562fn validate_consistency_against_fleet(
564 consistency: &ConsistencySection,
565 fleet: &FleetSection,
566) -> Result<(), ManifestValidationError> {
567 let fleet_roles = fleet
568 .members
569 .iter()
570 .map(|member| member.role.as_str())
571 .collect::<BTreeSet<_>>();
572 let mut covered_roles = BTreeSet::new();
573
574 for unit in &consistency.backup_units {
575 for role in &unit.roles {
576 if !fleet_roles.contains(role.as_str()) {
577 return Err(ManifestValidationError::UnknownBackupUnitRole {
578 unit_id: unit.unit_id.clone(),
579 role: role.clone(),
580 });
581 }
582 covered_roles.insert(role.as_str());
583 }
584
585 for dependency in &unit.dependency_closure {
586 if !fleet_roles.contains(dependency.as_str()) {
587 return Err(ManifestValidationError::UnknownBackupUnitDependency {
588 unit_id: unit.unit_id.clone(),
589 dependency: dependency.clone(),
590 });
591 }
592 }
593
594 validate_backup_unit_topology(unit, fleet, &fleet_roles)?;
595 }
596
597 for role in &fleet_roles {
598 if !covered_roles.contains(role) {
599 return Err(ManifestValidationError::BackupUnitCoverageMissingRole {
600 role: (*role).to_string(),
601 });
602 }
603 }
604
605 Ok(())
606}
607
608fn validate_verification_against_fleet(
610 verification: &VerificationPlan,
611 fleet: &FleetSection,
612) -> Result<(), ManifestValidationError> {
613 let fleet_roles = fleet
614 .members
615 .iter()
616 .map(|member| member.role.as_str())
617 .collect::<BTreeSet<_>>();
618
619 for check in &verification.fleet_checks {
620 validate_verification_check_roles(check, &fleet_roles)?;
621 }
622
623 for member in &fleet.members {
624 for check in &member.verification_checks {
625 validate_verification_check_roles(check, &fleet_roles)?;
626 }
627 }
628
629 let mut member_check_roles = BTreeSet::new();
630 for member in &verification.member_checks {
631 if !fleet_roles.contains(member.role.as_str()) {
632 return Err(ManifestValidationError::UnknownVerificationRole {
633 role: member.role.clone(),
634 });
635 }
636 if !member_check_roles.insert(member.role.as_str()) {
637 return Err(ManifestValidationError::DuplicateMemberVerificationRole(
638 member.role.clone(),
639 ));
640 }
641 for check in &member.checks {
642 validate_verification_check_roles(check, &fleet_roles)?;
643 }
644 }
645
646 Ok(())
647}
648
649fn validate_verification_check_roles(
651 check: &VerificationCheck,
652 fleet_roles: &BTreeSet<&str>,
653) -> Result<(), ManifestValidationError> {
654 for role in &check.roles {
655 if !fleet_roles.contains(role.as_str()) {
656 return Err(ManifestValidationError::UnknownVerificationRole { role: role.clone() });
657 }
658 }
659
660 Ok(())
661}
662
663fn validate_backup_unit_topology(
665 unit: &BackupUnit,
666 fleet: &FleetSection,
667 fleet_roles: &BTreeSet<&str>,
668) -> Result<(), ManifestValidationError> {
669 match &unit.kind {
670 BackupUnitKind::WholeFleet => validate_whole_fleet_unit(unit, fleet_roles),
671 BackupUnitKind::SubtreeRooted => validate_subtree_unit(unit, fleet),
672 BackupUnitKind::ControlPlaneSubset | BackupUnitKind::Flat => Ok(()),
673 }
674}
675
676fn validate_whole_fleet_unit(
678 unit: &BackupUnit,
679 fleet_roles: &BTreeSet<&str>,
680) -> Result<(), ManifestValidationError> {
681 let unit_roles = unit
682 .roles
683 .iter()
684 .map(String::as_str)
685 .collect::<BTreeSet<_>>();
686 for role in fleet_roles {
687 if !unit_roles.contains(role) {
688 return Err(ManifestValidationError::WholeFleetUnitMissingRole {
689 unit_id: unit.unit_id.clone(),
690 role: (*role).to_string(),
691 });
692 }
693 }
694
695 Ok(())
696}
697
698fn validate_subtree_unit(
700 unit: &BackupUnit,
701 fleet: &FleetSection,
702) -> Result<(), ManifestValidationError> {
703 let unit_roles = unit
704 .roles
705 .iter()
706 .map(String::as_str)
707 .collect::<BTreeSet<_>>();
708 let members_by_id = fleet
709 .members
710 .iter()
711 .map(|member| (member.canister_id.as_str(), member))
712 .collect::<BTreeMap<_, _>>();
713 let unit_member_ids = fleet
714 .members
715 .iter()
716 .filter(|member| unit_roles.contains(member.role.as_str()))
717 .map(|member| member.canister_id.as_str())
718 .collect::<BTreeSet<_>>();
719
720 let root_count = fleet
721 .members
722 .iter()
723 .filter(|member| unit_member_ids.contains(member.canister_id.as_str()))
724 .filter(|member| {
725 member
726 .parent_canister_id
727 .as_deref()
728 .is_none_or(|parent| !unit_member_ids.contains(parent))
729 })
730 .count();
731 if root_count != 1 {
732 return Err(ManifestValidationError::SubtreeBackupUnitNotConnected {
733 unit_id: unit.unit_id.clone(),
734 });
735 }
736
737 for member in &fleet.members {
738 if unit_member_ids.contains(member.canister_id.as_str()) {
739 continue;
740 }
741
742 if let Some(parent) = first_unit_ancestor(member, &members_by_id, &unit_member_ids) {
743 return Err(
744 ManifestValidationError::SubtreeBackupUnitMissingDescendant {
745 unit_id: unit.unit_id.clone(),
746 parent: parent.to_string(),
747 descendant: member.canister_id.clone(),
748 },
749 );
750 }
751 }
752
753 Ok(())
754}
755
756fn first_unit_ancestor<'a>(
758 member: &'a FleetMember,
759 members_by_id: &BTreeMap<&'a str, &'a FleetMember>,
760 unit_member_ids: &BTreeSet<&'a str>,
761) -> Option<&'a str> {
762 let mut visited = BTreeSet::new();
763 let mut parent = member.parent_canister_id.as_deref();
764 while let Some(parent_id) = parent {
765 if unit_member_ids.contains(parent_id) {
766 return Some(parent_id);
767 }
768 if !visited.insert(parent_id) {
769 return None;
770 }
771 parent = members_by_id
772 .get(parent_id)
773 .and_then(|ancestor| ancestor.parent_canister_id.as_deref());
774 }
775
776 None
777}
778
779const fn validate_manifest_version(version: u16) -> Result<(), ManifestValidationError> {
781 if version == SUPPORTED_MANIFEST_VERSION {
782 Ok(())
783 } else {
784 Err(ManifestValidationError::UnsupportedManifestVersion(version))
785 }
786}
787
788fn validate_nonempty(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
790 if value.trim().is_empty() {
791 Err(ManifestValidationError::EmptyField(field))
792 } else {
793 Ok(())
794 }
795}
796
797fn validate_optional_nonempty(
799 field: &'static str,
800 value: Option<&str>,
801) -> Result<(), ManifestValidationError> {
802 if let Some(value) = value {
803 validate_nonempty(field, value)?;
804 }
805 Ok(())
806}
807
808fn validate_required_option(
810 field: &'static str,
811 value: Option<&str>,
812) -> Result<(), ManifestValidationError> {
813 match value {
814 Some(value) => validate_nonempty(field, value),
815 None => Err(ManifestValidationError::EmptyField(field)),
816 }
817}
818
819fn validate_unique_values<F>(
821 field: &'static str,
822 values: &[String],
823 error: F,
824) -> Result<(), ManifestValidationError>
825where
826 F: Fn(&str) -> ManifestValidationError,
827{
828 let mut seen = BTreeSet::new();
829 for value in values {
830 validate_nonempty(field, value)?;
831 if !seen.insert(value.as_str()) {
832 return Err(error(value));
833 }
834 }
835
836 Ok(())
837}
838
839fn validate_principal(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
841 validate_nonempty(field, value)?;
842 Principal::from_str(value)
843 .map(|_| ())
844 .map_err(|_| ManifestValidationError::InvalidPrincipal {
845 field,
846 value: value.to_string(),
847 })
848}
849
850fn validate_optional_principal(
852 field: &'static str,
853 value: Option<&str>,
854) -> Result<(), ManifestValidationError> {
855 if let Some(value) = value {
856 validate_principal(field, value)?;
857 }
858 Ok(())
859}
860
861fn validate_hash(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
863 const SHA256_HEX_LEN: usize = 64;
864 validate_nonempty(field, value)?;
865 if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
866 Ok(())
867 } else {
868 Err(ManifestValidationError::InvalidHash(field))
869 }
870}
871
872fn validate_optional_hash(
874 field: &'static str,
875 value: Option<&str>,
876) -> Result<(), ManifestValidationError> {
877 if let Some(value) = value {
878 validate_hash(field, value)?;
879 }
880 Ok(())
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886
887 const ROOT: &str = "aaaaa-aa";
888 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
889 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
890
891 fn valid_manifest() -> FleetBackupManifest {
893 FleetBackupManifest {
894 manifest_version: 1,
895 backup_id: "fbk_test_001".to_string(),
896 created_at: "2026-04-10T12:00:00Z".to_string(),
897 tool: ToolMetadata {
898 name: "canic".to_string(),
899 version: "v1".to_string(),
900 },
901 source: SourceMetadata {
902 environment: "local".to_string(),
903 root_canister: ROOT.to_string(),
904 },
905 consistency: ConsistencySection {
906 mode: ConsistencyMode::QuiescedUnit,
907 backup_units: vec![BackupUnit {
908 unit_id: "core".to_string(),
909 kind: BackupUnitKind::Flat,
910 roles: vec!["root".to_string(), "app".to_string()],
911 consistency_reason: Some("root and app state are coordinated".to_string()),
912 dependency_closure: vec!["root".to_string(), "app".to_string()],
913 topology_validation: "operator-declared-flat".to_string(),
914 quiescence_strategy: Some("standard-canic-hooks@v1".to_string()),
915 }],
916 },
917 fleet: FleetSection {
918 topology_hash_algorithm: "sha256".to_string(),
919 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
920 discovery_topology_hash: HASH.to_string(),
921 pre_snapshot_topology_hash: HASH.to_string(),
922 topology_hash: HASH.to_string(),
923 members: vec![
924 fleet_member("root", ROOT, None, IdentityMode::Fixed),
925 fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
926 ],
927 },
928 verification: VerificationPlan {
929 fleet_checks: vec![VerificationCheck {
930 kind: "root_ready".to_string(),
931 method: None,
932 roles: Vec::new(),
933 }],
934 member_checks: Vec::new(),
935 },
936 }
937 }
938
939 #[test]
940 fn valid_manifest_passes_validation() {
941 let manifest = valid_manifest();
942
943 manifest.validate().expect("manifest should validate");
944 }
945
946 #[test]
948 fn invalid_snapshot_checksum_fails_validation() {
949 let mut manifest = valid_manifest();
950 manifest.fleet.members[0].source_snapshot.checksum = Some("not-a-sha".to_string());
951
952 let err = manifest
953 .validate()
954 .expect_err("invalid snapshot checksum should fail");
955
956 assert!(matches!(
957 err,
958 ManifestValidationError::InvalidHash("fleet.members[].source_snapshot.checksum")
959 ));
960 }
961
962 fn fleet_member(
964 role: &str,
965 canister_id: &str,
966 parent_canister_id: Option<&str>,
967 identity_mode: IdentityMode,
968 ) -> FleetMember {
969 FleetMember {
970 role: role.to_string(),
971 canister_id: canister_id.to_string(),
972 parent_canister_id: parent_canister_id.map(str::to_string),
973 subnet_canister_id: Some(CHILD.to_string()),
974 controller_hint: Some(ROOT.to_string()),
975 identity_mode,
976 restore_group: 1,
977 verification_class: "basic".to_string(),
978 verification_checks: vec![VerificationCheck {
979 kind: "call".to_string(),
980 method: Some("canic_ready".to_string()),
981 roles: Vec::new(),
982 }],
983 source_snapshot: SourceSnapshot {
984 snapshot_id: format!("snap-{role}"),
985 module_hash: Some(HASH.to_string()),
986 wasm_hash: Some(HASH.to_string()),
987 code_version: Some("v0.30.0".to_string()),
988 artifact_path: format!("artifacts/{role}"),
989 checksum_algorithm: "sha256".to_string(),
990 checksum: Some(HASH.to_string()),
991 },
992 }
993 }
994
995 #[test]
996 fn topology_hash_mismatch_fails_validation() {
997 let mut manifest = valid_manifest();
998 manifest.fleet.pre_snapshot_topology_hash =
999 "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string();
1000
1001 let err = manifest.validate().expect_err("mismatch should fail");
1002
1003 assert!(matches!(
1004 err,
1005 ManifestValidationError::TopologyHashMismatch { .. }
1006 ));
1007 }
1008
1009 #[test]
1010 fn missing_member_verification_checks_fail_validation() {
1011 let mut manifest = valid_manifest();
1012 manifest.fleet.members[0].verification_checks.clear();
1013
1014 let err = manifest
1015 .validate()
1016 .expect_err("missing member checks should fail");
1017
1018 assert!(matches!(
1019 err,
1020 ManifestValidationError::MissingMemberVerificationChecks(_)
1021 ));
1022 }
1023
1024 #[test]
1025 fn quiesced_unit_requires_quiescence_strategy() {
1026 let mut manifest = valid_manifest();
1027 manifest.consistency.backup_units[0].quiescence_strategy = None;
1028
1029 let err = manifest
1030 .validate()
1031 .expect_err("missing quiescence strategy should fail");
1032
1033 assert!(matches!(err, ManifestValidationError::EmptyField(_)));
1034 }
1035
1036 #[test]
1037 fn backup_unit_roles_must_exist_in_fleet() {
1038 let mut manifest = valid_manifest();
1039 manifest.consistency.backup_units[0]
1040 .roles
1041 .push("missing-role".to_string());
1042
1043 let err = manifest
1044 .validate()
1045 .expect_err("unknown backup unit role should fail");
1046
1047 assert!(matches!(
1048 err,
1049 ManifestValidationError::UnknownBackupUnitRole { .. }
1050 ));
1051 }
1052
1053 #[test]
1054 fn backup_unit_dependencies_must_exist_in_fleet() {
1055 let mut manifest = valid_manifest();
1056 manifest.consistency.backup_units[0]
1057 .dependency_closure
1058 .push("missing-dependency".to_string());
1059
1060 let err = manifest
1061 .validate()
1062 .expect_err("unknown backup unit dependency should fail");
1063
1064 assert!(matches!(
1065 err,
1066 ManifestValidationError::UnknownBackupUnitDependency { .. }
1067 ));
1068 }
1069
1070 #[test]
1071 fn backup_unit_ids_must_be_unique() {
1072 let mut manifest = valid_manifest();
1073 manifest
1074 .consistency
1075 .backup_units
1076 .push(manifest.consistency.backup_units[0].clone());
1077
1078 let err = manifest
1079 .validate()
1080 .expect_err("duplicate unit IDs should fail");
1081
1082 assert!(matches!(
1083 err,
1084 ManifestValidationError::DuplicateBackupUnitId(_)
1085 ));
1086 }
1087
1088 #[test]
1089 fn backup_unit_roles_must_be_unique() {
1090 let mut manifest = valid_manifest();
1091 manifest.consistency.backup_units[0]
1092 .roles
1093 .push("root".to_string());
1094
1095 let err = manifest
1096 .validate()
1097 .expect_err("duplicate backup unit role should fail");
1098
1099 assert!(matches!(
1100 err,
1101 ManifestValidationError::DuplicateBackupUnitRole { .. }
1102 ));
1103 }
1104
1105 #[test]
1106 fn backup_unit_dependencies_must_be_unique() {
1107 let mut manifest = valid_manifest();
1108 manifest.consistency.backup_units[0]
1109 .dependency_closure
1110 .push("root".to_string());
1111
1112 let err = manifest
1113 .validate()
1114 .expect_err("duplicate backup unit dependency should fail");
1115
1116 assert!(matches!(
1117 err,
1118 ManifestValidationError::DuplicateBackupUnitDependency { .. }
1119 ));
1120 }
1121
1122 #[test]
1123 fn every_fleet_role_must_be_covered_by_a_backup_unit() {
1124 let mut manifest = valid_manifest();
1125 manifest.consistency.backup_units[0].roles = vec!["root".to_string()];
1126 manifest.consistency.backup_units[0].dependency_closure = vec!["root".to_string()];
1127
1128 let err = manifest
1129 .validate()
1130 .expect_err("uncovered app role should fail");
1131
1132 assert!(matches!(
1133 err,
1134 ManifestValidationError::BackupUnitCoverageMissingRole { .. }
1135 ));
1136 }
1137
1138 #[test]
1139 fn fleet_verification_roles_must_exist_in_fleet() {
1140 let mut manifest = valid_manifest();
1141 manifest.verification.fleet_checks[0]
1142 .roles
1143 .push("missing-role".to_string());
1144
1145 let err = manifest
1146 .validate()
1147 .expect_err("unknown fleet verification role should fail");
1148
1149 assert!(matches!(
1150 err,
1151 ManifestValidationError::UnknownVerificationRole { .. }
1152 ));
1153 }
1154
1155 #[test]
1156 fn member_verification_check_roles_must_exist_in_fleet() {
1157 let mut manifest = valid_manifest();
1158 manifest.fleet.members[0].verification_checks[0]
1159 .roles
1160 .push("missing-role".to_string());
1161
1162 let err = manifest
1163 .validate()
1164 .expect_err("unknown member verification check role should fail");
1165
1166 assert!(matches!(
1167 err,
1168 ManifestValidationError::UnknownVerificationRole { .. }
1169 ));
1170 }
1171
1172 #[test]
1173 fn verification_check_roles_must_be_unique() {
1174 let mut manifest = valid_manifest();
1175 manifest.verification.fleet_checks[0]
1176 .roles
1177 .push("root".to_string());
1178 manifest.verification.fleet_checks[0]
1179 .roles
1180 .push("root".to_string());
1181
1182 let err = manifest
1183 .validate()
1184 .expect_err("duplicate verification role filter should fail");
1185
1186 assert!(matches!(
1187 err,
1188 ManifestValidationError::DuplicateVerificationCheckRole { .. }
1189 ));
1190 }
1191
1192 #[test]
1193 fn member_verification_group_roles_must_exist_in_fleet() {
1194 let mut manifest = valid_manifest();
1195 manifest
1196 .verification
1197 .member_checks
1198 .push(MemberVerificationChecks {
1199 role: "missing-role".to_string(),
1200 checks: vec![VerificationCheck {
1201 kind: "ready".to_string(),
1202 method: None,
1203 roles: Vec::new(),
1204 }],
1205 });
1206
1207 let err = manifest
1208 .validate()
1209 .expect_err("unknown member verification role should fail");
1210
1211 assert!(matches!(
1212 err,
1213 ManifestValidationError::UnknownVerificationRole { .. }
1214 ));
1215 }
1216
1217 #[test]
1218 fn member_verification_group_roles_must_be_unique() {
1219 let mut manifest = valid_manifest();
1220 manifest
1221 .verification
1222 .member_checks
1223 .push(member_verification_checks("root"));
1224 manifest
1225 .verification
1226 .member_checks
1227 .push(member_verification_checks("root"));
1228
1229 let err = manifest
1230 .validate()
1231 .expect_err("duplicate member verification role should fail");
1232
1233 assert!(matches!(
1234 err,
1235 ManifestValidationError::DuplicateMemberVerificationRole(_)
1236 ));
1237 }
1238
1239 #[test]
1240 fn nested_member_verification_roles_must_exist_in_fleet() {
1241 let mut manifest = valid_manifest();
1242 let mut checks = member_verification_checks("root");
1243 checks.checks[0].roles.push("missing-role".to_string());
1244 manifest.verification.member_checks.push(checks);
1245
1246 let err = manifest
1247 .validate()
1248 .expect_err("unknown nested verification role should fail");
1249
1250 assert!(matches!(
1251 err,
1252 ManifestValidationError::UnknownVerificationRole { .. }
1253 ));
1254 }
1255
1256 #[test]
1257 fn whole_fleet_unit_must_cover_all_roles() {
1258 let mut manifest = valid_manifest();
1259 manifest.consistency.backup_units[0].kind = BackupUnitKind::WholeFleet;
1260 manifest.consistency.backup_units[0].roles = vec!["root".to_string()];
1261 manifest.consistency.backup_units[0].consistency_reason = None;
1262
1263 let err = manifest
1264 .validate()
1265 .expect_err("whole-fleet unit missing app role should fail");
1266
1267 assert!(matches!(
1268 err,
1269 ManifestValidationError::WholeFleetUnitMissingRole { .. }
1270 ));
1271 }
1272
1273 #[test]
1274 fn subtree_unit_must_be_closed_under_descendants() {
1275 let mut manifest = valid_manifest();
1276 manifest.consistency.backup_units[0].kind = BackupUnitKind::SubtreeRooted;
1277 manifest.consistency.backup_units[0].roles = vec!["root".to_string()];
1278 manifest.consistency.backup_units[0].consistency_reason = None;
1279
1280 let err = manifest
1281 .validate()
1282 .expect_err("subtree unit omitting app child should fail");
1283
1284 assert!(matches!(
1285 err,
1286 ManifestValidationError::SubtreeBackupUnitMissingDescendant { .. }
1287 ));
1288 }
1289
1290 #[test]
1291 fn subtree_unit_must_be_connected() {
1292 let mut manifest = valid_manifest();
1293 manifest.fleet.members.push(fleet_member(
1294 "worker",
1295 "r7inp-6aaaa-aaaaa-aaabq-cai",
1296 None,
1297 IdentityMode::Relocatable,
1298 ));
1299 manifest.consistency.backup_units[0].kind = BackupUnitKind::SubtreeRooted;
1300 manifest.consistency.backup_units[0].roles = vec!["app".to_string(), "worker".to_string()];
1301 manifest.consistency.backup_units[0].consistency_reason = None;
1302 manifest.consistency.backup_units[0]
1303 .dependency_closure
1304 .push("worker".to_string());
1305
1306 let err = manifest
1307 .validate()
1308 .expect_err("disconnected subtree unit should fail");
1309
1310 assert!(matches!(
1311 err,
1312 ManifestValidationError::SubtreeBackupUnitNotConnected { .. }
1313 ));
1314 }
1315
1316 #[test]
1317 fn manifest_round_trips_through_json() {
1318 let manifest = valid_manifest();
1319
1320 let encoded = serde_json::to_string(&manifest).expect("serialize manifest");
1321 let decoded: FleetBackupManifest =
1322 serde_json::from_str(&encoded).expect("deserialize manifest");
1323
1324 decoded
1325 .validate()
1326 .expect("decoded manifest should validate");
1327 }
1328
1329 fn member_verification_checks(role: &str) -> MemberVerificationChecks {
1331 MemberVerificationChecks {
1332 role: role.to_string(),
1333 checks: vec![VerificationCheck {
1334 kind: "ready".to_string(),
1335 method: None,
1336 roles: Vec::new(),
1337 }],
1338 }
1339 }
1340}