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";
12
13#[derive(Clone, Debug, Deserialize, Serialize)]
18pub struct FleetBackupManifest {
19 pub manifest_version: u16,
20 pub backup_id: String,
21 pub created_at: String,
22 pub tool: ToolMetadata,
23 pub source: SourceMetadata,
24 pub consistency: ConsistencySection,
25 pub fleet: FleetSection,
26 pub verification: VerificationPlan,
27}
28
29impl FleetBackupManifest {
30 pub fn validate(&self) -> Result<(), ManifestValidationError> {
32 validate_manifest_version(self.manifest_version)?;
33 validate_nonempty("backup_id", &self.backup_id)?;
34 validate_nonempty("created_at", &self.created_at)?;
35 self.tool.validate()?;
36 self.source.validate()?;
37 self.consistency.validate()?;
38 self.fleet.validate()?;
39 self.verification.validate()?;
40 validate_consistency_against_fleet(&self.consistency, &self.fleet)?;
41 validate_verification_against_fleet(&self.verification, &self.fleet)?;
42 Ok(())
43 }
44}
45
46#[must_use]
48pub fn manifest_validation_summary(manifest: &FleetBackupManifest) -> serde_json::Value {
49 json!({
50 "status": "valid",
51 "backup_id": manifest.backup_id,
52 "members": manifest.fleet.members.len(),
53 "backup_unit_count": manifest.consistency.backup_units.len(),
54 "topology_hash": manifest.fleet.topology_hash,
55 "topology_hash_algorithm": manifest.fleet.topology_hash_algorithm,
56 "topology_hash_input": manifest.fleet.topology_hash_input,
57 "topology_validation_status": "validated",
58 "backup_unit_kinds": backup_unit_kind_counts(manifest),
59 "backup_units": manifest
60 .consistency
61 .backup_units
62 .iter()
63 .map(|unit| json!({
64 "unit_id": unit.unit_id,
65 "kind": backup_unit_kind_name(&unit.kind),
66 "role_count": unit.roles.len(),
67 }))
68 .collect::<Vec<_>>(),
69 })
70}
71
72fn backup_unit_kind_counts(manifest: &FleetBackupManifest) -> serde_json::Value {
74 let mut single = 0;
75 let mut subtree = 0;
76 for unit in &manifest.consistency.backup_units {
77 match &unit.kind {
78 BackupUnitKind::Single => single += 1,
79 BackupUnitKind::Subtree => subtree += 1,
80 }
81 }
82
83 json!({
84 "single": single,
85 "subtree": subtree,
86 })
87}
88
89pub(crate) const fn backup_unit_kind_name(kind: &BackupUnitKind) -> &'static str {
91 match kind {
92 BackupUnitKind::Single => "single",
93 BackupUnitKind::Subtree => "subtree",
94 }
95}
96
97#[derive(Clone, Debug, Deserialize, Serialize)]
102pub struct ToolMetadata {
103 pub name: String,
104 pub version: String,
105}
106
107impl ToolMetadata {
108 pub(crate) fn validate(&self) -> Result<(), ManifestValidationError> {
110 validate_nonempty("tool.name", &self.name)?;
111 validate_nonempty("tool.version", &self.version)
112 }
113}
114
115#[derive(Clone, Debug, Deserialize, Serialize)]
120pub struct SourceMetadata {
121 pub environment: String,
122 pub root_canister: String,
123}
124
125impl SourceMetadata {
126 fn validate(&self) -> Result<(), ManifestValidationError> {
128 validate_nonempty("source.environment", &self.environment)?;
129 validate_principal("source.root_canister", &self.root_canister)
130 }
131}
132
133#[derive(Clone, Debug, Deserialize, Serialize)]
138pub struct ConsistencySection {
139 pub backup_units: Vec<BackupUnit>,
140}
141
142impl ConsistencySection {
143 fn validate(&self) -> Result<(), ManifestValidationError> {
145 if self.backup_units.is_empty() {
146 return Err(ManifestValidationError::EmptyCollection(
147 "consistency.backup_units",
148 ));
149 }
150
151 let mut unit_ids = BTreeSet::new();
152 for unit in &self.backup_units {
153 unit.validate()?;
154 if !unit_ids.insert(unit.unit_id.clone()) {
155 return Err(ManifestValidationError::DuplicateBackupUnitId(
156 unit.unit_id.clone(),
157 ));
158 }
159 }
160
161 Ok(())
162 }
163}
164
165#[derive(Clone, Debug, Deserialize, Serialize)]
170pub struct BackupUnit {
171 pub unit_id: String,
172 pub kind: BackupUnitKind,
173 pub roles: Vec<String>,
174}
175
176impl BackupUnit {
177 fn validate(&self) -> Result<(), ManifestValidationError> {
179 validate_nonempty("consistency.backup_units[].unit_id", &self.unit_id)?;
180
181 if self.roles.is_empty() {
182 return Err(ManifestValidationError::EmptyCollection(
183 "consistency.backup_units[].roles",
184 ));
185 }
186
187 for role in &self.roles {
188 validate_nonempty("consistency.backup_units[].roles[]", role)?;
189 }
190 validate_unique_values("consistency.backup_units[].roles[]", &self.roles, |role| {
191 ManifestValidationError::DuplicateBackupUnitRole {
192 unit_id: self.unit_id.clone(),
193 role: role.to_string(),
194 }
195 })?;
196
197 Ok(())
198 }
199}
200
201#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
206#[serde(rename_all = "kebab-case")]
207pub enum BackupUnitKind {
208 Single,
209 Subtree,
210}
211
212#[derive(Clone, Debug, Deserialize, Serialize)]
217pub struct FleetSection {
218 pub topology_hash_algorithm: String,
219 pub topology_hash_input: String,
220 pub discovery_topology_hash: String,
221 pub pre_snapshot_topology_hash: String,
222 pub topology_hash: String,
223 pub members: Vec<FleetMember>,
224}
225
226impl FleetSection {
227 pub(crate) fn validate(&self) -> Result<(), ManifestValidationError> {
229 validate_nonempty(
230 "fleet.topology_hash_algorithm",
231 &self.topology_hash_algorithm,
232 )?;
233 if self.topology_hash_algorithm != SHA256_ALGORITHM {
234 return Err(ManifestValidationError::UnsupportedHashAlgorithm(
235 self.topology_hash_algorithm.clone(),
236 ));
237 }
238
239 validate_nonempty("fleet.topology_hash_input", &self.topology_hash_input)?;
240 validate_hash(
241 "fleet.discovery_topology_hash",
242 &self.discovery_topology_hash,
243 )?;
244 validate_hash(
245 "fleet.pre_snapshot_topology_hash",
246 &self.pre_snapshot_topology_hash,
247 )?;
248 validate_hash("fleet.topology_hash", &self.topology_hash)?;
249
250 if self.discovery_topology_hash != self.pre_snapshot_topology_hash {
251 return Err(ManifestValidationError::TopologyHashMismatch {
252 discovery: self.discovery_topology_hash.clone(),
253 pre_snapshot: self.pre_snapshot_topology_hash.clone(),
254 });
255 }
256
257 if self.topology_hash != self.discovery_topology_hash {
258 return Err(ManifestValidationError::AcceptedTopologyHashMismatch {
259 accepted: self.topology_hash.clone(),
260 discovery: self.discovery_topology_hash.clone(),
261 });
262 }
263
264 if self.members.is_empty() {
265 return Err(ManifestValidationError::EmptyCollection("fleet.members"));
266 }
267
268 let mut canister_ids = BTreeSet::new();
269 for member in &self.members {
270 member.validate()?;
271 if !canister_ids.insert(member.canister_id.clone()) {
272 return Err(ManifestValidationError::DuplicateCanisterId(
273 member.canister_id.clone(),
274 ));
275 }
276 }
277
278 Ok(())
279 }
280}
281
282#[derive(Clone, Debug, Deserialize, Serialize)]
287pub struct FleetMember {
288 pub role: String,
289 pub canister_id: String,
290 pub parent_canister_id: Option<String>,
291 pub subnet_canister_id: Option<String>,
292 pub controller_hint: Option<String>,
293 pub identity_mode: IdentityMode,
294 pub verification_checks: Vec<VerificationCheck>,
295 pub source_snapshot: SourceSnapshot,
296}
297
298impl FleetMember {
299 fn validate(&self) -> Result<(), ManifestValidationError> {
301 validate_nonempty("fleet.members[].role", &self.role)?;
302 validate_principal("fleet.members[].canister_id", &self.canister_id)?;
303 validate_optional_principal(
304 "fleet.members[].parent_canister_id",
305 self.parent_canister_id.as_deref(),
306 )?;
307 validate_optional_principal(
308 "fleet.members[].subnet_canister_id",
309 self.subnet_canister_id.as_deref(),
310 )?;
311 validate_optional_principal(
312 "fleet.members[].controller_hint",
313 self.controller_hint.as_deref(),
314 )?;
315
316 if self.verification_checks.is_empty() {
317 return Err(ManifestValidationError::MissingMemberVerificationChecks(
318 self.canister_id.clone(),
319 ));
320 }
321
322 for check in &self.verification_checks {
323 check.validate()?;
324 }
325
326 self.source_snapshot.validate()
327 }
328}
329
330#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
335#[serde(rename_all = "kebab-case")]
336pub enum IdentityMode {
337 Fixed,
338 Relocatable,
339}
340
341#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
346pub struct SourceSnapshot {
347 pub snapshot_id: String,
348 pub module_hash: Option<String>,
349 pub wasm_hash: Option<String>,
350 pub code_version: Option<String>,
351 pub artifact_path: String,
352 pub checksum_algorithm: String,
353 #[serde(default)]
354 pub checksum: Option<String>,
355}
356
357impl SourceSnapshot {
358 fn validate(&self) -> Result<(), ManifestValidationError> {
360 validate_nonempty(
361 "fleet.members[].source_snapshot.snapshot_id",
362 &self.snapshot_id,
363 )?;
364 validate_optional_nonempty(
365 "fleet.members[].source_snapshot.module_hash",
366 self.module_hash.as_deref(),
367 )?;
368 validate_optional_nonempty(
369 "fleet.members[].source_snapshot.wasm_hash",
370 self.wasm_hash.as_deref(),
371 )?;
372 validate_optional_nonempty(
373 "fleet.members[].source_snapshot.code_version",
374 self.code_version.as_deref(),
375 )?;
376 validate_nonempty(
377 "fleet.members[].source_snapshot.artifact_path",
378 &self.artifact_path,
379 )?;
380 validate_nonempty(
381 "fleet.members[].source_snapshot.checksum_algorithm",
382 &self.checksum_algorithm,
383 )?;
384 if self.checksum_algorithm != SHA256_ALGORITHM {
385 return Err(ManifestValidationError::UnsupportedHashAlgorithm(
386 self.checksum_algorithm.clone(),
387 ));
388 }
389 validate_optional_hash(
390 "fleet.members[].source_snapshot.checksum",
391 self.checksum.as_deref(),
392 )?;
393 Ok(())
394 }
395}
396
397#[derive(Clone, Debug, Default, Deserialize, Serialize)]
402pub struct VerificationPlan {
403 pub fleet_checks: Vec<VerificationCheck>,
404 pub member_checks: Vec<MemberVerificationChecks>,
405}
406
407impl VerificationPlan {
408 fn validate(&self) -> Result<(), ManifestValidationError> {
410 for check in &self.fleet_checks {
411 check.validate()?;
412 }
413 for member in &self.member_checks {
414 member.validate()?;
415 }
416 Ok(())
417 }
418}
419
420#[derive(Clone, Debug, Deserialize, Serialize)]
425pub struct MemberVerificationChecks {
426 pub role: String,
427 pub checks: Vec<VerificationCheck>,
428}
429
430impl MemberVerificationChecks {
431 fn validate(&self) -> Result<(), ManifestValidationError> {
433 validate_nonempty("verification.member_checks[].role", &self.role)?;
434 if self.checks.is_empty() {
435 return Err(ManifestValidationError::EmptyCollection(
436 "verification.member_checks[].checks",
437 ));
438 }
439 for check in &self.checks {
440 check.validate()?;
441 }
442 Ok(())
443 }
444}
445
446#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
451pub struct VerificationCheck {
452 pub kind: String,
453 pub roles: Vec<String>,
454}
455
456impl VerificationCheck {
457 fn validate(&self) -> Result<(), ManifestValidationError> {
459 validate_nonempty("verification.check.kind", &self.kind)?;
460 if self.kind != "status" {
461 return Err(ManifestValidationError::UnsupportedVerificationKind(
462 self.kind.clone(),
463 ));
464 }
465 for role in &self.roles {
466 validate_nonempty("verification.check.roles[]", role)?;
467 }
468 validate_unique_values("verification.check.roles[]", &self.roles, |role| {
469 ManifestValidationError::DuplicateVerificationCheckRole {
470 kind: self.kind.clone(),
471 role: role.to_string(),
472 }
473 })?;
474 Ok(())
475 }
476}
477
478#[derive(Debug, ThisError)]
483pub enum ManifestValidationError {
484 #[error("unsupported manifest version {0}")]
485 UnsupportedManifestVersion(u16),
486
487 #[error("field {0} must not be empty")]
488 EmptyField(&'static str),
489
490 #[error("collection {0} must not be empty")]
491 EmptyCollection(&'static str),
492
493 #[error("field {field} must be a valid principal: {value}")]
494 InvalidPrincipal { field: &'static str, value: String },
495
496 #[error("field {0} must be a non-empty sha256 hex string")]
497 InvalidHash(&'static str),
498
499 #[error("unsupported hash algorithm {0}")]
500 UnsupportedHashAlgorithm(String),
501
502 #[error("unsupported verification kind {0}")]
503 UnsupportedVerificationKind(String),
504
505 #[error("topology hash mismatch between discovery {discovery} and pre-snapshot {pre_snapshot}")]
506 TopologyHashMismatch {
507 discovery: String,
508 pre_snapshot: String,
509 },
510
511 #[error("accepted topology hash {accepted} does not match discovery hash {discovery}")]
512 AcceptedTopologyHashMismatch { accepted: String, discovery: String },
513
514 #[error("duplicate canister id {0}")]
515 DuplicateCanisterId(String),
516
517 #[error("duplicate backup unit id {0}")]
518 DuplicateBackupUnitId(String),
519
520 #[error("backup unit {unit_id} repeats role {role}")]
521 DuplicateBackupUnitRole { unit_id: String, role: String },
522
523 #[error("fleet member {0} has no concrete verification checks")]
524 MissingMemberVerificationChecks(String),
525
526 #[error("backup unit {unit_id} references unknown role {role}")]
527 UnknownBackupUnitRole { unit_id: String, role: String },
528
529 #[error("fleet role {role} is not covered by any backup unit")]
530 BackupUnitCoverageMissingRole { role: String },
531
532 #[error("verification plan references unknown role {role}")]
533 UnknownVerificationRole { role: String },
534
535 #[error("duplicate member verification role {0}")]
536 DuplicateMemberVerificationRole(String),
537
538 #[error("verification check {kind} repeats role {role}")]
539 DuplicateVerificationCheckRole { kind: String, role: String },
540
541 #[error("subtree backup unit {unit_id} is not connected")]
542 SubtreeBackupUnitNotConnected { unit_id: String },
543
544 #[error(
545 "subtree backup unit {unit_id} includes parent {parent} but omits descendant {descendant}"
546 )]
547 SubtreeBackupUnitMissingDescendant {
548 unit_id: String,
549 parent: String,
550 descendant: String,
551 },
552}
553
554fn validate_consistency_against_fleet(
556 consistency: &ConsistencySection,
557 fleet: &FleetSection,
558) -> Result<(), ManifestValidationError> {
559 let fleet_roles = fleet
560 .members
561 .iter()
562 .map(|member| member.role.as_str())
563 .collect::<BTreeSet<_>>();
564 let mut covered_roles = BTreeSet::new();
565
566 for unit in &consistency.backup_units {
567 for role in &unit.roles {
568 if !fleet_roles.contains(role.as_str()) {
569 return Err(ManifestValidationError::UnknownBackupUnitRole {
570 unit_id: unit.unit_id.clone(),
571 role: role.clone(),
572 });
573 }
574 covered_roles.insert(role.as_str());
575 }
576
577 validate_backup_unit_topology(unit, fleet)?;
578 }
579
580 for role in &fleet_roles {
581 if !covered_roles.contains(role) {
582 return Err(ManifestValidationError::BackupUnitCoverageMissingRole {
583 role: (*role).to_string(),
584 });
585 }
586 }
587
588 Ok(())
589}
590
591fn validate_verification_against_fleet(
593 verification: &VerificationPlan,
594 fleet: &FleetSection,
595) -> Result<(), ManifestValidationError> {
596 let fleet_roles = fleet
597 .members
598 .iter()
599 .map(|member| member.role.as_str())
600 .collect::<BTreeSet<_>>();
601
602 for check in &verification.fleet_checks {
603 validate_verification_check_roles(check, &fleet_roles)?;
604 }
605
606 for member in &fleet.members {
607 for check in &member.verification_checks {
608 validate_verification_check_roles(check, &fleet_roles)?;
609 }
610 }
611
612 let mut member_check_roles = BTreeSet::new();
613 for member in &verification.member_checks {
614 if !fleet_roles.contains(member.role.as_str()) {
615 return Err(ManifestValidationError::UnknownVerificationRole {
616 role: member.role.clone(),
617 });
618 }
619 if !member_check_roles.insert(member.role.as_str()) {
620 return Err(ManifestValidationError::DuplicateMemberVerificationRole(
621 member.role.clone(),
622 ));
623 }
624 for check in &member.checks {
625 validate_verification_check_roles(check, &fleet_roles)?;
626 }
627 }
628
629 Ok(())
630}
631
632fn validate_verification_check_roles(
634 check: &VerificationCheck,
635 fleet_roles: &BTreeSet<&str>,
636) -> Result<(), ManifestValidationError> {
637 for role in &check.roles {
638 if !fleet_roles.contains(role.as_str()) {
639 return Err(ManifestValidationError::UnknownVerificationRole { role: role.clone() });
640 }
641 }
642
643 Ok(())
644}
645
646fn validate_backup_unit_topology(
648 unit: &BackupUnit,
649 fleet: &FleetSection,
650) -> Result<(), ManifestValidationError> {
651 match &unit.kind {
652 BackupUnitKind::Subtree => validate_subtree_unit(unit, fleet),
653 BackupUnitKind::Single => Ok(()),
654 }
655}
656
657fn validate_subtree_unit(
659 unit: &BackupUnit,
660 fleet: &FleetSection,
661) -> Result<(), ManifestValidationError> {
662 let unit_roles = unit
663 .roles
664 .iter()
665 .map(String::as_str)
666 .collect::<BTreeSet<_>>();
667 let members_by_id = fleet
668 .members
669 .iter()
670 .map(|member| (member.canister_id.as_str(), member))
671 .collect::<BTreeMap<_, _>>();
672 let unit_member_ids = fleet
673 .members
674 .iter()
675 .filter(|member| unit_roles.contains(member.role.as_str()))
676 .map(|member| member.canister_id.as_str())
677 .collect::<BTreeSet<_>>();
678
679 let root_count = fleet
680 .members
681 .iter()
682 .filter(|member| unit_member_ids.contains(member.canister_id.as_str()))
683 .filter(|member| {
684 member
685 .parent_canister_id
686 .as_deref()
687 .is_none_or(|parent| !unit_member_ids.contains(parent))
688 })
689 .count();
690 if root_count != 1 {
691 return Err(ManifestValidationError::SubtreeBackupUnitNotConnected {
692 unit_id: unit.unit_id.clone(),
693 });
694 }
695
696 for member in &fleet.members {
697 if unit_member_ids.contains(member.canister_id.as_str()) {
698 continue;
699 }
700
701 if let Some(parent) = first_unit_ancestor(member, &members_by_id, &unit_member_ids) {
702 return Err(
703 ManifestValidationError::SubtreeBackupUnitMissingDescendant {
704 unit_id: unit.unit_id.clone(),
705 parent: parent.to_string(),
706 descendant: member.canister_id.clone(),
707 },
708 );
709 }
710 }
711
712 Ok(())
713}
714
715fn first_unit_ancestor<'a>(
717 member: &'a FleetMember,
718 members_by_id: &BTreeMap<&'a str, &'a FleetMember>,
719 unit_member_ids: &BTreeSet<&'a str>,
720) -> Option<&'a str> {
721 let mut visited = BTreeSet::new();
722 let mut parent = member.parent_canister_id.as_deref();
723 while let Some(parent_id) = parent {
724 if unit_member_ids.contains(parent_id) {
725 return Some(parent_id);
726 }
727 if !visited.insert(parent_id) {
728 return None;
729 }
730 parent = members_by_id
731 .get(parent_id)
732 .and_then(|ancestor| ancestor.parent_canister_id.as_deref());
733 }
734
735 None
736}
737
738const fn validate_manifest_version(version: u16) -> Result<(), ManifestValidationError> {
740 if version == SUPPORTED_MANIFEST_VERSION {
741 Ok(())
742 } else {
743 Err(ManifestValidationError::UnsupportedManifestVersion(version))
744 }
745}
746
747fn validate_nonempty(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
749 if value.trim().is_empty() {
750 Err(ManifestValidationError::EmptyField(field))
751 } else {
752 Ok(())
753 }
754}
755
756fn validate_optional_nonempty(
758 field: &'static str,
759 value: Option<&str>,
760) -> Result<(), ManifestValidationError> {
761 if let Some(value) = value {
762 validate_nonempty(field, value)?;
763 }
764 Ok(())
765}
766
767fn validate_unique_values<F>(
769 field: &'static str,
770 values: &[String],
771 error: F,
772) -> Result<(), ManifestValidationError>
773where
774 F: Fn(&str) -> ManifestValidationError,
775{
776 let mut seen = BTreeSet::new();
777 for value in values {
778 validate_nonempty(field, value)?;
779 if !seen.insert(value.as_str()) {
780 return Err(error(value));
781 }
782 }
783
784 Ok(())
785}
786
787fn validate_principal(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
789 validate_nonempty(field, value)?;
790 Principal::from_str(value)
791 .map(|_| ())
792 .map_err(|_| ManifestValidationError::InvalidPrincipal {
793 field,
794 value: value.to_string(),
795 })
796}
797
798fn validate_optional_principal(
800 field: &'static str,
801 value: Option<&str>,
802) -> Result<(), ManifestValidationError> {
803 if let Some(value) = value {
804 validate_principal(field, value)?;
805 }
806 Ok(())
807}
808
809fn validate_hash(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
811 const SHA256_HEX_LEN: usize = 64;
812 validate_nonempty(field, value)?;
813 if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
814 Ok(())
815 } else {
816 Err(ManifestValidationError::InvalidHash(field))
817 }
818}
819
820fn validate_optional_hash(
822 field: &'static str,
823 value: Option<&str>,
824) -> Result<(), ManifestValidationError> {
825 if let Some(value) = value {
826 validate_hash(field, value)?;
827 }
828 Ok(())
829}
830
831#[cfg(test)]
832mod tests;