1use candid::Principal;
2use serde::{Deserialize, Serialize};
3use std::{collections::BTreeSet, str::FromStr};
4use thiserror::Error as ThisError;
5
6const SUPPORTED_MANIFEST_VERSION: u16 = 1;
7const SHA256_ALGORITHM: &str = "sha256";
8
9#[derive(Clone, Debug, Deserialize, Serialize)]
14pub struct FleetBackupManifest {
15 pub manifest_version: u16,
16 pub backup_id: String,
17 pub created_at: String,
18 pub tool: ToolMetadata,
19 pub source: SourceMetadata,
20 pub consistency: ConsistencySection,
21 pub fleet: FleetSection,
22 pub verification: VerificationPlan,
23}
24
25impl FleetBackupManifest {
26 pub fn validate(&self) -> Result<(), ManifestValidationError> {
28 validate_manifest_version(self.manifest_version)?;
29 validate_nonempty("backup_id", &self.backup_id)?;
30 validate_nonempty("created_at", &self.created_at)?;
31 self.tool.validate()?;
32 self.source.validate()?;
33 self.consistency.validate()?;
34 self.fleet.validate()?;
35 self.verification.validate()?;
36 validate_consistency_against_fleet(&self.consistency, &self.fleet)?;
37 Ok(())
38 }
39}
40
41#[derive(Clone, Debug, Deserialize, Serialize)]
46pub struct ToolMetadata {
47 pub name: String,
48 pub version: String,
49}
50
51impl ToolMetadata {
52 pub(crate) fn validate(&self) -> Result<(), ManifestValidationError> {
54 validate_nonempty("tool.name", &self.name)?;
55 validate_nonempty("tool.version", &self.version)
56 }
57}
58
59#[derive(Clone, Debug, Deserialize, Serialize)]
64pub struct SourceMetadata {
65 pub environment: String,
66 pub root_canister: String,
67}
68
69impl SourceMetadata {
70 fn validate(&self) -> Result<(), ManifestValidationError> {
72 validate_nonempty("source.environment", &self.environment)?;
73 validate_principal("source.root_canister", &self.root_canister)
74 }
75}
76
77#[derive(Clone, Debug, Deserialize, Serialize)]
82pub struct ConsistencySection {
83 pub mode: ConsistencyMode,
84 pub backup_units: Vec<BackupUnit>,
85}
86
87impl ConsistencySection {
88 fn validate(&self) -> Result<(), ManifestValidationError> {
90 if self.backup_units.is_empty() {
91 return Err(ManifestValidationError::EmptyCollection(
92 "consistency.backup_units",
93 ));
94 }
95
96 for unit in &self.backup_units {
97 unit.validate(&self.mode)?;
98 }
99
100 Ok(())
101 }
102}
103
104#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
109#[serde(rename_all = "kebab-case")]
110pub enum ConsistencyMode {
111 CrashConsistent,
112 QuiescedUnit,
113}
114
115#[derive(Clone, Debug, Deserialize, Serialize)]
120pub struct BackupUnit {
121 pub unit_id: String,
122 pub kind: BackupUnitKind,
123 pub roles: Vec<String>,
124 pub consistency_reason: Option<String>,
125 pub dependency_closure: Vec<String>,
126 pub topology_validation: String,
127 pub quiescence_strategy: Option<String>,
128}
129
130impl BackupUnit {
131 fn validate(&self, mode: &ConsistencyMode) -> Result<(), ManifestValidationError> {
133 validate_nonempty("consistency.backup_units[].unit_id", &self.unit_id)?;
134 validate_nonempty(
135 "consistency.backup_units[].topology_validation",
136 &self.topology_validation,
137 )?;
138
139 if self.roles.is_empty() {
140 return Err(ManifestValidationError::EmptyCollection(
141 "consistency.backup_units[].roles",
142 ));
143 }
144
145 for role in &self.roles {
146 validate_nonempty("consistency.backup_units[].roles[]", role)?;
147 }
148
149 for dependency in &self.dependency_closure {
150 validate_nonempty(
151 "consistency.backup_units[].dependency_closure[]",
152 dependency,
153 )?;
154 }
155
156 if matches!(self.kind, BackupUnitKind::Flat) {
157 validate_required_option(
158 "consistency.backup_units[].consistency_reason",
159 self.consistency_reason.as_deref(),
160 )?;
161 }
162
163 if matches!(mode, ConsistencyMode::QuiescedUnit) {
164 validate_required_option(
165 "consistency.backup_units[].quiescence_strategy",
166 self.quiescence_strategy.as_deref(),
167 )?;
168 }
169
170 Ok(())
171 }
172}
173
174#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
179#[serde(rename_all = "kebab-case")]
180pub enum BackupUnitKind {
181 WholeFleet,
182 ControlPlaneSubset,
183 SubtreeRooted,
184 Flat,
185}
186
187#[derive(Clone, Debug, Deserialize, Serialize)]
192pub struct FleetSection {
193 pub topology_hash_algorithm: String,
194 pub topology_hash_input: String,
195 pub discovery_topology_hash: String,
196 pub pre_snapshot_topology_hash: String,
197 pub topology_hash: String,
198 pub members: Vec<FleetMember>,
199}
200
201impl FleetSection {
202 pub(crate) fn validate(&self) -> Result<(), ManifestValidationError> {
204 validate_nonempty(
205 "fleet.topology_hash_algorithm",
206 &self.topology_hash_algorithm,
207 )?;
208 if self.topology_hash_algorithm != SHA256_ALGORITHM {
209 return Err(ManifestValidationError::UnsupportedHashAlgorithm(
210 self.topology_hash_algorithm.clone(),
211 ));
212 }
213
214 validate_nonempty("fleet.topology_hash_input", &self.topology_hash_input)?;
215 validate_hash(
216 "fleet.discovery_topology_hash",
217 &self.discovery_topology_hash,
218 )?;
219 validate_hash(
220 "fleet.pre_snapshot_topology_hash",
221 &self.pre_snapshot_topology_hash,
222 )?;
223 validate_hash("fleet.topology_hash", &self.topology_hash)?;
224
225 if self.discovery_topology_hash != self.pre_snapshot_topology_hash {
226 return Err(ManifestValidationError::TopologyHashMismatch {
227 discovery: self.discovery_topology_hash.clone(),
228 pre_snapshot: self.pre_snapshot_topology_hash.clone(),
229 });
230 }
231
232 if self.topology_hash != self.discovery_topology_hash {
233 return Err(ManifestValidationError::AcceptedTopologyHashMismatch {
234 accepted: self.topology_hash.clone(),
235 discovery: self.discovery_topology_hash.clone(),
236 });
237 }
238
239 if self.members.is_empty() {
240 return Err(ManifestValidationError::EmptyCollection("fleet.members"));
241 }
242
243 let mut canister_ids = BTreeSet::new();
244 for member in &self.members {
245 member.validate()?;
246 if !canister_ids.insert(member.canister_id.clone()) {
247 return Err(ManifestValidationError::DuplicateCanisterId(
248 member.canister_id.clone(),
249 ));
250 }
251 }
252
253 Ok(())
254 }
255}
256
257#[derive(Clone, Debug, Deserialize, Serialize)]
262pub struct FleetMember {
263 pub role: String,
264 pub canister_id: String,
265 pub parent_canister_id: Option<String>,
266 pub subnet_canister_id: Option<String>,
267 pub controller_hint: Option<String>,
268 pub identity_mode: IdentityMode,
269 pub restore_group: u16,
270 pub verification_class: String,
271 pub verification_checks: Vec<VerificationCheck>,
272 pub source_snapshot: SourceSnapshot,
273}
274
275impl FleetMember {
276 fn validate(&self) -> Result<(), ManifestValidationError> {
278 validate_nonempty("fleet.members[].role", &self.role)?;
279 validate_principal("fleet.members[].canister_id", &self.canister_id)?;
280 validate_optional_principal(
281 "fleet.members[].parent_canister_id",
282 self.parent_canister_id.as_deref(),
283 )?;
284 validate_optional_principal(
285 "fleet.members[].subnet_canister_id",
286 self.subnet_canister_id.as_deref(),
287 )?;
288 validate_optional_principal(
289 "fleet.members[].controller_hint",
290 self.controller_hint.as_deref(),
291 )?;
292 validate_nonempty(
293 "fleet.members[].verification_class",
294 &self.verification_class,
295 )?;
296
297 if self.verification_checks.is_empty() {
298 return Err(ManifestValidationError::MissingMemberVerificationChecks(
299 self.canister_id.clone(),
300 ));
301 }
302
303 for check in &self.verification_checks {
304 check.validate()?;
305 }
306
307 self.source_snapshot.validate()
308 }
309}
310
311#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
316#[serde(rename_all = "kebab-case")]
317pub enum IdentityMode {
318 Fixed,
319 Relocatable,
320}
321
322#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
327pub struct SourceSnapshot {
328 pub snapshot_id: String,
329 pub module_hash: Option<String>,
330 pub wasm_hash: Option<String>,
331 pub code_version: Option<String>,
332 pub artifact_path: String,
333 pub checksum_algorithm: String,
334 #[serde(default)]
335 pub checksum: Option<String>,
336}
337
338impl SourceSnapshot {
339 fn validate(&self) -> Result<(), ManifestValidationError> {
341 validate_nonempty(
342 "fleet.members[].source_snapshot.snapshot_id",
343 &self.snapshot_id,
344 )?;
345 validate_optional_nonempty(
346 "fleet.members[].source_snapshot.module_hash",
347 self.module_hash.as_deref(),
348 )?;
349 validate_optional_nonempty(
350 "fleet.members[].source_snapshot.wasm_hash",
351 self.wasm_hash.as_deref(),
352 )?;
353 validate_optional_nonempty(
354 "fleet.members[].source_snapshot.code_version",
355 self.code_version.as_deref(),
356 )?;
357 validate_nonempty(
358 "fleet.members[].source_snapshot.artifact_path",
359 &self.artifact_path,
360 )?;
361 validate_nonempty(
362 "fleet.members[].source_snapshot.checksum_algorithm",
363 &self.checksum_algorithm,
364 )?;
365 if self.checksum_algorithm != SHA256_ALGORITHM {
366 return Err(ManifestValidationError::UnsupportedHashAlgorithm(
367 self.checksum_algorithm.clone(),
368 ));
369 }
370 validate_optional_hash(
371 "fleet.members[].source_snapshot.checksum",
372 self.checksum.as_deref(),
373 )?;
374 Ok(())
375 }
376}
377
378#[derive(Clone, Debug, Default, Deserialize, Serialize)]
383pub struct VerificationPlan {
384 pub fleet_checks: Vec<VerificationCheck>,
385 pub member_checks: Vec<MemberVerificationChecks>,
386}
387
388impl VerificationPlan {
389 fn validate(&self) -> Result<(), ManifestValidationError> {
391 for check in &self.fleet_checks {
392 check.validate()?;
393 }
394 for member in &self.member_checks {
395 member.validate()?;
396 }
397 Ok(())
398 }
399}
400
401#[derive(Clone, Debug, Deserialize, Serialize)]
406pub struct MemberVerificationChecks {
407 pub role: String,
408 pub checks: Vec<VerificationCheck>,
409}
410
411impl MemberVerificationChecks {
412 fn validate(&self) -> Result<(), ManifestValidationError> {
414 validate_nonempty("verification.member_checks[].role", &self.role)?;
415 if self.checks.is_empty() {
416 return Err(ManifestValidationError::EmptyCollection(
417 "verification.member_checks[].checks",
418 ));
419 }
420 for check in &self.checks {
421 check.validate()?;
422 }
423 Ok(())
424 }
425}
426
427#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
432pub struct VerificationCheck {
433 pub kind: String,
434 pub method: Option<String>,
435 pub roles: Vec<String>,
436}
437
438impl VerificationCheck {
439 fn validate(&self) -> Result<(), ManifestValidationError> {
441 validate_nonempty("verification.check.kind", &self.kind)?;
442 validate_optional_nonempty("verification.check.method", self.method.as_deref())?;
443 for role in &self.roles {
444 validate_nonempty("verification.check.roles[]", role)?;
445 }
446 Ok(())
447 }
448}
449
450#[derive(Debug, ThisError)]
455pub enum ManifestValidationError {
456 #[error("unsupported manifest version {0}")]
457 UnsupportedManifestVersion(u16),
458
459 #[error("field {0} must not be empty")]
460 EmptyField(&'static str),
461
462 #[error("collection {0} must not be empty")]
463 EmptyCollection(&'static str),
464
465 #[error("field {field} must be a valid principal: {value}")]
466 InvalidPrincipal { field: &'static str, value: String },
467
468 #[error("field {0} must be a non-empty sha256 hex string")]
469 InvalidHash(&'static str),
470
471 #[error("unsupported hash algorithm {0}")]
472 UnsupportedHashAlgorithm(String),
473
474 #[error("topology hash mismatch between discovery {discovery} and pre-snapshot {pre_snapshot}")]
475 TopologyHashMismatch {
476 discovery: String,
477 pre_snapshot: String,
478 },
479
480 #[error("accepted topology hash {accepted} does not match discovery hash {discovery}")]
481 AcceptedTopologyHashMismatch { accepted: String, discovery: String },
482
483 #[error("duplicate canister id {0}")]
484 DuplicateCanisterId(String),
485
486 #[error("fleet member {0} has no concrete verification checks")]
487 MissingMemberVerificationChecks(String),
488
489 #[error("backup unit {unit_id} references unknown role {role}")]
490 UnknownBackupUnitRole { unit_id: String, role: String },
491
492 #[error("backup unit {unit_id} references unknown dependency {dependency}")]
493 UnknownBackupUnitDependency { unit_id: String, dependency: String },
494}
495
496fn validate_consistency_against_fleet(
498 consistency: &ConsistencySection,
499 fleet: &FleetSection,
500) -> Result<(), ManifestValidationError> {
501 let fleet_roles = fleet
502 .members
503 .iter()
504 .map(|member| member.role.as_str())
505 .collect::<BTreeSet<_>>();
506
507 for unit in &consistency.backup_units {
508 for role in &unit.roles {
509 if !fleet_roles.contains(role.as_str()) {
510 return Err(ManifestValidationError::UnknownBackupUnitRole {
511 unit_id: unit.unit_id.clone(),
512 role: role.clone(),
513 });
514 }
515 }
516
517 for dependency in &unit.dependency_closure {
518 if !fleet_roles.contains(dependency.as_str()) {
519 return Err(ManifestValidationError::UnknownBackupUnitDependency {
520 unit_id: unit.unit_id.clone(),
521 dependency: dependency.clone(),
522 });
523 }
524 }
525 }
526
527 Ok(())
528}
529
530const fn validate_manifest_version(version: u16) -> Result<(), ManifestValidationError> {
532 if version == SUPPORTED_MANIFEST_VERSION {
533 Ok(())
534 } else {
535 Err(ManifestValidationError::UnsupportedManifestVersion(version))
536 }
537}
538
539fn validate_nonempty(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
541 if value.trim().is_empty() {
542 Err(ManifestValidationError::EmptyField(field))
543 } else {
544 Ok(())
545 }
546}
547
548fn validate_optional_nonempty(
550 field: &'static str,
551 value: Option<&str>,
552) -> Result<(), ManifestValidationError> {
553 if let Some(value) = value {
554 validate_nonempty(field, value)?;
555 }
556 Ok(())
557}
558
559fn validate_required_option(
561 field: &'static str,
562 value: Option<&str>,
563) -> Result<(), ManifestValidationError> {
564 match value {
565 Some(value) => validate_nonempty(field, value),
566 None => Err(ManifestValidationError::EmptyField(field)),
567 }
568}
569
570fn validate_principal(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
572 validate_nonempty(field, value)?;
573 Principal::from_str(value)
574 .map(|_| ())
575 .map_err(|_| ManifestValidationError::InvalidPrincipal {
576 field,
577 value: value.to_string(),
578 })
579}
580
581fn validate_optional_principal(
583 field: &'static str,
584 value: Option<&str>,
585) -> Result<(), ManifestValidationError> {
586 if let Some(value) = value {
587 validate_principal(field, value)?;
588 }
589 Ok(())
590}
591
592fn validate_hash(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
594 const SHA256_HEX_LEN: usize = 64;
595 validate_nonempty(field, value)?;
596 if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
597 Ok(())
598 } else {
599 Err(ManifestValidationError::InvalidHash(field))
600 }
601}
602
603fn validate_optional_hash(
605 field: &'static str,
606 value: Option<&str>,
607) -> Result<(), ManifestValidationError> {
608 if let Some(value) = value {
609 validate_hash(field, value)?;
610 }
611 Ok(())
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 const ROOT: &str = "aaaaa-aa";
619 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
620 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
621
622 fn valid_manifest() -> FleetBackupManifest {
624 FleetBackupManifest {
625 manifest_version: 1,
626 backup_id: "fbk_test_001".to_string(),
627 created_at: "2026-04-10T12:00:00Z".to_string(),
628 tool: ToolMetadata {
629 name: "canic".to_string(),
630 version: "v1".to_string(),
631 },
632 source: SourceMetadata {
633 environment: "local".to_string(),
634 root_canister: ROOT.to_string(),
635 },
636 consistency: ConsistencySection {
637 mode: ConsistencyMode::QuiescedUnit,
638 backup_units: vec![BackupUnit {
639 unit_id: "core".to_string(),
640 kind: BackupUnitKind::Flat,
641 roles: vec!["root".to_string(), "app".to_string()],
642 consistency_reason: Some("root and app state are coordinated".to_string()),
643 dependency_closure: vec!["root".to_string(), "app".to_string()],
644 topology_validation: "operator-declared-flat".to_string(),
645 quiescence_strategy: Some("standard-canic-hooks@v1".to_string()),
646 }],
647 },
648 fleet: FleetSection {
649 topology_hash_algorithm: "sha256".to_string(),
650 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
651 discovery_topology_hash: HASH.to_string(),
652 pre_snapshot_topology_hash: HASH.to_string(),
653 topology_hash: HASH.to_string(),
654 members: vec![
655 fleet_member("root", ROOT, None, IdentityMode::Fixed),
656 fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
657 ],
658 },
659 verification: VerificationPlan {
660 fleet_checks: vec![VerificationCheck {
661 kind: "root_ready".to_string(),
662 method: None,
663 roles: Vec::new(),
664 }],
665 member_checks: Vec::new(),
666 },
667 }
668 }
669
670 #[test]
671 fn valid_manifest_passes_validation() {
672 let manifest = valid_manifest();
673
674 manifest.validate().expect("manifest should validate");
675 }
676
677 #[test]
679 fn invalid_snapshot_checksum_fails_validation() {
680 let mut manifest = valid_manifest();
681 manifest.fleet.members[0].source_snapshot.checksum = Some("not-a-sha".to_string());
682
683 let err = manifest
684 .validate()
685 .expect_err("invalid snapshot checksum should fail");
686
687 assert!(matches!(
688 err,
689 ManifestValidationError::InvalidHash("fleet.members[].source_snapshot.checksum")
690 ));
691 }
692
693 fn fleet_member(
695 role: &str,
696 canister_id: &str,
697 parent_canister_id: Option<&str>,
698 identity_mode: IdentityMode,
699 ) -> FleetMember {
700 FleetMember {
701 role: role.to_string(),
702 canister_id: canister_id.to_string(),
703 parent_canister_id: parent_canister_id.map(str::to_string),
704 subnet_canister_id: Some(CHILD.to_string()),
705 controller_hint: Some(ROOT.to_string()),
706 identity_mode,
707 restore_group: 1,
708 verification_class: "basic".to_string(),
709 verification_checks: vec![VerificationCheck {
710 kind: "call".to_string(),
711 method: Some("canic_ready".to_string()),
712 roles: Vec::new(),
713 }],
714 source_snapshot: SourceSnapshot {
715 snapshot_id: format!("snap-{role}"),
716 module_hash: Some(HASH.to_string()),
717 wasm_hash: Some(HASH.to_string()),
718 code_version: Some("v0.30.0".to_string()),
719 artifact_path: format!("artifacts/{role}"),
720 checksum_algorithm: "sha256".to_string(),
721 checksum: Some(HASH.to_string()),
722 },
723 }
724 }
725
726 #[test]
727 fn topology_hash_mismatch_fails_validation() {
728 let mut manifest = valid_manifest();
729 manifest.fleet.pre_snapshot_topology_hash =
730 "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string();
731
732 let err = manifest.validate().expect_err("mismatch should fail");
733
734 assert!(matches!(
735 err,
736 ManifestValidationError::TopologyHashMismatch { .. }
737 ));
738 }
739
740 #[test]
741 fn missing_member_verification_checks_fail_validation() {
742 let mut manifest = valid_manifest();
743 manifest.fleet.members[0].verification_checks.clear();
744
745 let err = manifest
746 .validate()
747 .expect_err("missing member checks should fail");
748
749 assert!(matches!(
750 err,
751 ManifestValidationError::MissingMemberVerificationChecks(_)
752 ));
753 }
754
755 #[test]
756 fn quiesced_unit_requires_quiescence_strategy() {
757 let mut manifest = valid_manifest();
758 manifest.consistency.backup_units[0].quiescence_strategy = None;
759
760 let err = manifest
761 .validate()
762 .expect_err("missing quiescence strategy should fail");
763
764 assert!(matches!(err, ManifestValidationError::EmptyField(_)));
765 }
766
767 #[test]
768 fn backup_unit_roles_must_exist_in_fleet() {
769 let mut manifest = valid_manifest();
770 manifest.consistency.backup_units[0]
771 .roles
772 .push("missing-role".to_string());
773
774 let err = manifest
775 .validate()
776 .expect_err("unknown backup unit role should fail");
777
778 assert!(matches!(
779 err,
780 ManifestValidationError::UnknownBackupUnitRole { .. }
781 ));
782 }
783
784 #[test]
785 fn backup_unit_dependencies_must_exist_in_fleet() {
786 let mut manifest = valid_manifest();
787 manifest.consistency.backup_units[0]
788 .dependency_closure
789 .push("missing-dependency".to_string());
790
791 let err = manifest
792 .validate()
793 .expect_err("unknown backup unit dependency should fail");
794
795 assert!(matches!(
796 err,
797 ManifestValidationError::UnknownBackupUnitDependency { .. }
798 ));
799 }
800
801 #[test]
802 fn manifest_round_trips_through_json() {
803 let manifest = valid_manifest();
804
805 let encoded = serde_json::to_string(&manifest).expect("serialize manifest");
806 let decoded: FleetBackupManifest =
807 serde_json::from_str(&encoded).expect("deserialize manifest");
808
809 decoded
810 .validate()
811 .expect("decoded manifest should validate");
812 }
813}