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}
335
336impl SourceSnapshot {
337 fn validate(&self) -> Result<(), ManifestValidationError> {
339 validate_nonempty(
340 "fleet.members[].source_snapshot.snapshot_id",
341 &self.snapshot_id,
342 )?;
343 validate_optional_nonempty(
344 "fleet.members[].source_snapshot.module_hash",
345 self.module_hash.as_deref(),
346 )?;
347 validate_optional_nonempty(
348 "fleet.members[].source_snapshot.wasm_hash",
349 self.wasm_hash.as_deref(),
350 )?;
351 validate_optional_nonempty(
352 "fleet.members[].source_snapshot.code_version",
353 self.code_version.as_deref(),
354 )?;
355 validate_nonempty(
356 "fleet.members[].source_snapshot.artifact_path",
357 &self.artifact_path,
358 )?;
359 validate_nonempty(
360 "fleet.members[].source_snapshot.checksum_algorithm",
361 &self.checksum_algorithm,
362 )?;
363 if self.checksum_algorithm != SHA256_ALGORITHM {
364 return Err(ManifestValidationError::UnsupportedHashAlgorithm(
365 self.checksum_algorithm.clone(),
366 ));
367 }
368 Ok(())
369 }
370}
371
372#[derive(Clone, Debug, Default, Deserialize, Serialize)]
377pub struct VerificationPlan {
378 pub fleet_checks: Vec<VerificationCheck>,
379 pub member_checks: Vec<MemberVerificationChecks>,
380}
381
382impl VerificationPlan {
383 fn validate(&self) -> Result<(), ManifestValidationError> {
385 for check in &self.fleet_checks {
386 check.validate()?;
387 }
388 for member in &self.member_checks {
389 member.validate()?;
390 }
391 Ok(())
392 }
393}
394
395#[derive(Clone, Debug, Deserialize, Serialize)]
400pub struct MemberVerificationChecks {
401 pub role: String,
402 pub checks: Vec<VerificationCheck>,
403}
404
405impl MemberVerificationChecks {
406 fn validate(&self) -> Result<(), ManifestValidationError> {
408 validate_nonempty("verification.member_checks[].role", &self.role)?;
409 if self.checks.is_empty() {
410 return Err(ManifestValidationError::EmptyCollection(
411 "verification.member_checks[].checks",
412 ));
413 }
414 for check in &self.checks {
415 check.validate()?;
416 }
417 Ok(())
418 }
419}
420
421#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
426pub struct VerificationCheck {
427 pub kind: String,
428 pub method: Option<String>,
429 pub roles: Vec<String>,
430}
431
432impl VerificationCheck {
433 fn validate(&self) -> Result<(), ManifestValidationError> {
435 validate_nonempty("verification.check.kind", &self.kind)?;
436 validate_optional_nonempty("verification.check.method", self.method.as_deref())?;
437 for role in &self.roles {
438 validate_nonempty("verification.check.roles[]", role)?;
439 }
440 Ok(())
441 }
442}
443
444#[derive(Debug, ThisError)]
449pub enum ManifestValidationError {
450 #[error("unsupported manifest version {0}")]
451 UnsupportedManifestVersion(u16),
452
453 #[error("field {0} must not be empty")]
454 EmptyField(&'static str),
455
456 #[error("collection {0} must not be empty")]
457 EmptyCollection(&'static str),
458
459 #[error("field {field} must be a valid principal: {value}")]
460 InvalidPrincipal { field: &'static str, value: String },
461
462 #[error("field {0} must be a non-empty sha256 hex string")]
463 InvalidHash(&'static str),
464
465 #[error("unsupported hash algorithm {0}")]
466 UnsupportedHashAlgorithm(String),
467
468 #[error("topology hash mismatch between discovery {discovery} and pre-snapshot {pre_snapshot}")]
469 TopologyHashMismatch {
470 discovery: String,
471 pre_snapshot: String,
472 },
473
474 #[error("accepted topology hash {accepted} does not match discovery hash {discovery}")]
475 AcceptedTopologyHashMismatch { accepted: String, discovery: String },
476
477 #[error("duplicate canister id {0}")]
478 DuplicateCanisterId(String),
479
480 #[error("fleet member {0} has no concrete verification checks")]
481 MissingMemberVerificationChecks(String),
482
483 #[error("backup unit {unit_id} references unknown role {role}")]
484 UnknownBackupUnitRole { unit_id: String, role: String },
485
486 #[error("backup unit {unit_id} references unknown dependency {dependency}")]
487 UnknownBackupUnitDependency { unit_id: String, dependency: String },
488}
489
490fn validate_consistency_against_fleet(
492 consistency: &ConsistencySection,
493 fleet: &FleetSection,
494) -> Result<(), ManifestValidationError> {
495 let fleet_roles = fleet
496 .members
497 .iter()
498 .map(|member| member.role.as_str())
499 .collect::<BTreeSet<_>>();
500
501 for unit in &consistency.backup_units {
502 for role in &unit.roles {
503 if !fleet_roles.contains(role.as_str()) {
504 return Err(ManifestValidationError::UnknownBackupUnitRole {
505 unit_id: unit.unit_id.clone(),
506 role: role.clone(),
507 });
508 }
509 }
510
511 for dependency in &unit.dependency_closure {
512 if !fleet_roles.contains(dependency.as_str()) {
513 return Err(ManifestValidationError::UnknownBackupUnitDependency {
514 unit_id: unit.unit_id.clone(),
515 dependency: dependency.clone(),
516 });
517 }
518 }
519 }
520
521 Ok(())
522}
523
524const fn validate_manifest_version(version: u16) -> Result<(), ManifestValidationError> {
526 if version == SUPPORTED_MANIFEST_VERSION {
527 Ok(())
528 } else {
529 Err(ManifestValidationError::UnsupportedManifestVersion(version))
530 }
531}
532
533fn validate_nonempty(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
535 if value.trim().is_empty() {
536 Err(ManifestValidationError::EmptyField(field))
537 } else {
538 Ok(())
539 }
540}
541
542fn validate_optional_nonempty(
544 field: &'static str,
545 value: Option<&str>,
546) -> Result<(), ManifestValidationError> {
547 if let Some(value) = value {
548 validate_nonempty(field, value)?;
549 }
550 Ok(())
551}
552
553fn validate_required_option(
555 field: &'static str,
556 value: Option<&str>,
557) -> Result<(), ManifestValidationError> {
558 match value {
559 Some(value) => validate_nonempty(field, value),
560 None => Err(ManifestValidationError::EmptyField(field)),
561 }
562}
563
564fn validate_principal(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
566 validate_nonempty(field, value)?;
567 Principal::from_str(value)
568 .map(|_| ())
569 .map_err(|_| ManifestValidationError::InvalidPrincipal {
570 field,
571 value: value.to_string(),
572 })
573}
574
575fn validate_optional_principal(
577 field: &'static str,
578 value: Option<&str>,
579) -> Result<(), ManifestValidationError> {
580 if let Some(value) = value {
581 validate_principal(field, value)?;
582 }
583 Ok(())
584}
585
586fn validate_hash(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
588 const SHA256_HEX_LEN: usize = 64;
589 validate_nonempty(field, value)?;
590 if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
591 Ok(())
592 } else {
593 Err(ManifestValidationError::InvalidHash(field))
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 const ROOT: &str = "aaaaa-aa";
602 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
603 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
604
605 fn valid_manifest() -> FleetBackupManifest {
607 FleetBackupManifest {
608 manifest_version: 1,
609 backup_id: "fbk_test_001".to_string(),
610 created_at: "2026-04-10T12:00:00Z".to_string(),
611 tool: ToolMetadata {
612 name: "canic".to_string(),
613 version: "v1".to_string(),
614 },
615 source: SourceMetadata {
616 environment: "local".to_string(),
617 root_canister: ROOT.to_string(),
618 },
619 consistency: ConsistencySection {
620 mode: ConsistencyMode::QuiescedUnit,
621 backup_units: vec![BackupUnit {
622 unit_id: "core".to_string(),
623 kind: BackupUnitKind::Flat,
624 roles: vec!["root".to_string(), "app".to_string()],
625 consistency_reason: Some("root and app state are coordinated".to_string()),
626 dependency_closure: vec!["root".to_string(), "app".to_string()],
627 topology_validation: "operator-declared-flat".to_string(),
628 quiescence_strategy: Some("standard-canic-hooks@v1".to_string()),
629 }],
630 },
631 fleet: FleetSection {
632 topology_hash_algorithm: "sha256".to_string(),
633 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
634 discovery_topology_hash: HASH.to_string(),
635 pre_snapshot_topology_hash: HASH.to_string(),
636 topology_hash: HASH.to_string(),
637 members: vec![
638 fleet_member("root", ROOT, None, IdentityMode::Fixed),
639 fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
640 ],
641 },
642 verification: VerificationPlan {
643 fleet_checks: vec![VerificationCheck {
644 kind: "root_ready".to_string(),
645 method: None,
646 roles: Vec::new(),
647 }],
648 member_checks: Vec::new(),
649 },
650 }
651 }
652
653 #[test]
654 fn valid_manifest_passes_validation() {
655 let manifest = valid_manifest();
656
657 manifest.validate().expect("manifest should validate");
658 }
659
660 fn fleet_member(
662 role: &str,
663 canister_id: &str,
664 parent_canister_id: Option<&str>,
665 identity_mode: IdentityMode,
666 ) -> FleetMember {
667 FleetMember {
668 role: role.to_string(),
669 canister_id: canister_id.to_string(),
670 parent_canister_id: parent_canister_id.map(str::to_string),
671 subnet_canister_id: Some(CHILD.to_string()),
672 controller_hint: Some(ROOT.to_string()),
673 identity_mode,
674 restore_group: 1,
675 verification_class: "basic".to_string(),
676 verification_checks: vec![VerificationCheck {
677 kind: "call".to_string(),
678 method: Some("canic_ready".to_string()),
679 roles: Vec::new(),
680 }],
681 source_snapshot: SourceSnapshot {
682 snapshot_id: format!("snap-{role}"),
683 module_hash: Some(HASH.to_string()),
684 wasm_hash: Some(HASH.to_string()),
685 code_version: Some("v0.30.0".to_string()),
686 artifact_path: format!("artifacts/{role}"),
687 checksum_algorithm: "sha256".to_string(),
688 },
689 }
690 }
691
692 #[test]
693 fn topology_hash_mismatch_fails_validation() {
694 let mut manifest = valid_manifest();
695 manifest.fleet.pre_snapshot_topology_hash =
696 "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string();
697
698 let err = manifest.validate().expect_err("mismatch should fail");
699
700 assert!(matches!(
701 err,
702 ManifestValidationError::TopologyHashMismatch { .. }
703 ));
704 }
705
706 #[test]
707 fn missing_member_verification_checks_fail_validation() {
708 let mut manifest = valid_manifest();
709 manifest.fleet.members[0].verification_checks.clear();
710
711 let err = manifest
712 .validate()
713 .expect_err("missing member checks should fail");
714
715 assert!(matches!(
716 err,
717 ManifestValidationError::MissingMemberVerificationChecks(_)
718 ));
719 }
720
721 #[test]
722 fn quiesced_unit_requires_quiescence_strategy() {
723 let mut manifest = valid_manifest();
724 manifest.consistency.backup_units[0].quiescence_strategy = None;
725
726 let err = manifest
727 .validate()
728 .expect_err("missing quiescence strategy should fail");
729
730 assert!(matches!(err, ManifestValidationError::EmptyField(_)));
731 }
732
733 #[test]
734 fn backup_unit_roles_must_exist_in_fleet() {
735 let mut manifest = valid_manifest();
736 manifest.consistency.backup_units[0]
737 .roles
738 .push("missing-role".to_string());
739
740 let err = manifest
741 .validate()
742 .expect_err("unknown backup unit role should fail");
743
744 assert!(matches!(
745 err,
746 ManifestValidationError::UnknownBackupUnitRole { .. }
747 ));
748 }
749
750 #[test]
751 fn backup_unit_dependencies_must_exist_in_fleet() {
752 let mut manifest = valid_manifest();
753 manifest.consistency.backup_units[0]
754 .dependency_closure
755 .push("missing-dependency".to_string());
756
757 let err = manifest
758 .validate()
759 .expect_err("unknown backup unit dependency should fail");
760
761 assert!(matches!(
762 err,
763 ManifestValidationError::UnknownBackupUnitDependency { .. }
764 ));
765 }
766
767 #[test]
768 fn manifest_round_trips_through_json() {
769 let manifest = valid_manifest();
770
771 let encoded = serde_json::to_string(&manifest).expect("serialize manifest");
772 let decoded: FleetBackupManifest =
773 serde_json::from_str(&encoded).expect("deserialize manifest");
774
775 decoded
776 .validate()
777 .expect("decoded manifest should validate");
778 }
779}