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 code_version: Option<String>,
350 pub artifact_path: String,
351 pub checksum_algorithm: String,
352 #[serde(default)]
353 pub checksum: Option<String>,
354}
355
356impl SourceSnapshot {
357 fn validate(&self) -> Result<(), ManifestValidationError> {
359 validate_nonempty(
360 "fleet.members[].source_snapshot.snapshot_id",
361 &self.snapshot_id,
362 )?;
363 validate_optional_nonempty(
364 "fleet.members[].source_snapshot.module_hash",
365 self.module_hash.as_deref(),
366 )?;
367 validate_optional_nonempty(
368 "fleet.members[].source_snapshot.code_version",
369 self.code_version.as_deref(),
370 )?;
371 validate_nonempty(
372 "fleet.members[].source_snapshot.artifact_path",
373 &self.artifact_path,
374 )?;
375 validate_nonempty(
376 "fleet.members[].source_snapshot.checksum_algorithm",
377 &self.checksum_algorithm,
378 )?;
379 if self.checksum_algorithm != SHA256_ALGORITHM {
380 return Err(ManifestValidationError::UnsupportedHashAlgorithm(
381 self.checksum_algorithm.clone(),
382 ));
383 }
384 validate_optional_hash(
385 "fleet.members[].source_snapshot.checksum",
386 self.checksum.as_deref(),
387 )?;
388 Ok(())
389 }
390}
391
392#[derive(Clone, Debug, Default, Deserialize, Serialize)]
397pub struct VerificationPlan {
398 pub fleet_checks: Vec<VerificationCheck>,
399 pub member_checks: Vec<MemberVerificationChecks>,
400}
401
402impl VerificationPlan {
403 fn validate(&self) -> Result<(), ManifestValidationError> {
405 for check in &self.fleet_checks {
406 check.validate()?;
407 }
408 for member in &self.member_checks {
409 member.validate()?;
410 }
411 Ok(())
412 }
413}
414
415#[derive(Clone, Debug, Deserialize, Serialize)]
420pub struct MemberVerificationChecks {
421 pub role: String,
422 pub checks: Vec<VerificationCheck>,
423}
424
425impl MemberVerificationChecks {
426 fn validate(&self) -> Result<(), ManifestValidationError> {
428 validate_nonempty("verification.member_checks[].role", &self.role)?;
429 if self.checks.is_empty() {
430 return Err(ManifestValidationError::EmptyCollection(
431 "verification.member_checks[].checks",
432 ));
433 }
434 for check in &self.checks {
435 check.validate()?;
436 }
437 Ok(())
438 }
439}
440
441#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
446pub struct VerificationCheck {
447 pub kind: String,
448 pub roles: Vec<String>,
449}
450
451impl VerificationCheck {
452 fn validate(&self) -> Result<(), ManifestValidationError> {
454 validate_nonempty("verification.check.kind", &self.kind)?;
455 if self.kind != "status" {
456 return Err(ManifestValidationError::UnsupportedVerificationKind(
457 self.kind.clone(),
458 ));
459 }
460 for role in &self.roles {
461 validate_nonempty("verification.check.roles[]", role)?;
462 }
463 validate_unique_values("verification.check.roles[]", &self.roles, |role| {
464 ManifestValidationError::DuplicateVerificationCheckRole {
465 kind: self.kind.clone(),
466 role: role.to_string(),
467 }
468 })?;
469 Ok(())
470 }
471}
472
473#[derive(Debug, ThisError)]
478pub enum ManifestValidationError {
479 #[error("unsupported manifest version {0}")]
480 UnsupportedManifestVersion(u16),
481
482 #[error("field {0} must not be empty")]
483 EmptyField(&'static str),
484
485 #[error("collection {0} must not be empty")]
486 EmptyCollection(&'static str),
487
488 #[error("field {field} must be a valid principal: {value}")]
489 InvalidPrincipal { field: &'static str, value: String },
490
491 #[error("field {0} must be a non-empty sha256 hex string")]
492 InvalidHash(&'static str),
493
494 #[error("unsupported hash algorithm {0}")]
495 UnsupportedHashAlgorithm(String),
496
497 #[error("unsupported verification kind {0}")]
498 UnsupportedVerificationKind(String),
499
500 #[error("topology hash mismatch between discovery {discovery} and pre-snapshot {pre_snapshot}")]
501 TopologyHashMismatch {
502 discovery: String,
503 pre_snapshot: String,
504 },
505
506 #[error("accepted topology hash {accepted} does not match discovery hash {discovery}")]
507 AcceptedTopologyHashMismatch { accepted: String, discovery: String },
508
509 #[error("duplicate canister id {0}")]
510 DuplicateCanisterId(String),
511
512 #[error("duplicate backup unit id {0}")]
513 DuplicateBackupUnitId(String),
514
515 #[error("backup unit {unit_id} repeats role {role}")]
516 DuplicateBackupUnitRole { unit_id: String, role: String },
517
518 #[error("fleet member {0} has no concrete verification checks")]
519 MissingMemberVerificationChecks(String),
520
521 #[error("backup unit {unit_id} references unknown role {role}")]
522 UnknownBackupUnitRole { unit_id: String, role: String },
523
524 #[error("fleet role {role} is not covered by any backup unit")]
525 BackupUnitCoverageMissingRole { role: String },
526
527 #[error("verification plan references unknown role {role}")]
528 UnknownVerificationRole { role: String },
529
530 #[error("duplicate member verification role {0}")]
531 DuplicateMemberVerificationRole(String),
532
533 #[error("verification check {kind} repeats role {role}")]
534 DuplicateVerificationCheckRole { kind: String, role: String },
535
536 #[error("subtree backup unit {unit_id} is not connected")]
537 SubtreeBackupUnitNotConnected { unit_id: String },
538
539 #[error(
540 "subtree backup unit {unit_id} includes parent {parent} but omits descendant {descendant}"
541 )]
542 SubtreeBackupUnitMissingDescendant {
543 unit_id: String,
544 parent: String,
545 descendant: String,
546 },
547}
548
549fn validate_consistency_against_fleet(
551 consistency: &ConsistencySection,
552 fleet: &FleetSection,
553) -> Result<(), ManifestValidationError> {
554 let fleet_roles = fleet
555 .members
556 .iter()
557 .map(|member| member.role.as_str())
558 .collect::<BTreeSet<_>>();
559 let mut covered_roles = BTreeSet::new();
560
561 for unit in &consistency.backup_units {
562 for role in &unit.roles {
563 if !fleet_roles.contains(role.as_str()) {
564 return Err(ManifestValidationError::UnknownBackupUnitRole {
565 unit_id: unit.unit_id.clone(),
566 role: role.clone(),
567 });
568 }
569 covered_roles.insert(role.as_str());
570 }
571
572 validate_backup_unit_topology(unit, fleet)?;
573 }
574
575 for role in &fleet_roles {
576 if !covered_roles.contains(role) {
577 return Err(ManifestValidationError::BackupUnitCoverageMissingRole {
578 role: (*role).to_string(),
579 });
580 }
581 }
582
583 Ok(())
584}
585
586fn validate_verification_against_fleet(
588 verification: &VerificationPlan,
589 fleet: &FleetSection,
590) -> Result<(), ManifestValidationError> {
591 let fleet_roles = fleet
592 .members
593 .iter()
594 .map(|member| member.role.as_str())
595 .collect::<BTreeSet<_>>();
596
597 for check in &verification.fleet_checks {
598 validate_verification_check_roles(check, &fleet_roles)?;
599 }
600
601 for member in &fleet.members {
602 for check in &member.verification_checks {
603 validate_verification_check_roles(check, &fleet_roles)?;
604 }
605 }
606
607 let mut member_check_roles = BTreeSet::new();
608 for member in &verification.member_checks {
609 if !fleet_roles.contains(member.role.as_str()) {
610 return Err(ManifestValidationError::UnknownVerificationRole {
611 role: member.role.clone(),
612 });
613 }
614 if !member_check_roles.insert(member.role.as_str()) {
615 return Err(ManifestValidationError::DuplicateMemberVerificationRole(
616 member.role.clone(),
617 ));
618 }
619 for check in &member.checks {
620 validate_verification_check_roles(check, &fleet_roles)?;
621 }
622 }
623
624 Ok(())
625}
626
627fn validate_verification_check_roles(
629 check: &VerificationCheck,
630 fleet_roles: &BTreeSet<&str>,
631) -> Result<(), ManifestValidationError> {
632 for role in &check.roles {
633 if !fleet_roles.contains(role.as_str()) {
634 return Err(ManifestValidationError::UnknownVerificationRole { role: role.clone() });
635 }
636 }
637
638 Ok(())
639}
640
641fn validate_backup_unit_topology(
643 unit: &BackupUnit,
644 fleet: &FleetSection,
645) -> Result<(), ManifestValidationError> {
646 match &unit.kind {
647 BackupUnitKind::Subtree => validate_subtree_unit(unit, fleet),
648 BackupUnitKind::Single => Ok(()),
649 }
650}
651
652fn validate_subtree_unit(
654 unit: &BackupUnit,
655 fleet: &FleetSection,
656) -> Result<(), ManifestValidationError> {
657 let unit_roles = unit
658 .roles
659 .iter()
660 .map(String::as_str)
661 .collect::<BTreeSet<_>>();
662 let members_by_id = fleet
663 .members
664 .iter()
665 .map(|member| (member.canister_id.as_str(), member))
666 .collect::<BTreeMap<_, _>>();
667 let unit_member_ids = fleet
668 .members
669 .iter()
670 .filter(|member| unit_roles.contains(member.role.as_str()))
671 .map(|member| member.canister_id.as_str())
672 .collect::<BTreeSet<_>>();
673
674 let root_count = fleet
675 .members
676 .iter()
677 .filter(|member| unit_member_ids.contains(member.canister_id.as_str()))
678 .filter(|member| {
679 member
680 .parent_canister_id
681 .as_deref()
682 .is_none_or(|parent| !unit_member_ids.contains(parent))
683 })
684 .count();
685 if root_count != 1 {
686 return Err(ManifestValidationError::SubtreeBackupUnitNotConnected {
687 unit_id: unit.unit_id.clone(),
688 });
689 }
690
691 for member in &fleet.members {
692 if unit_member_ids.contains(member.canister_id.as_str()) {
693 continue;
694 }
695
696 if let Some(parent) = first_unit_ancestor(member, &members_by_id, &unit_member_ids) {
697 return Err(
698 ManifestValidationError::SubtreeBackupUnitMissingDescendant {
699 unit_id: unit.unit_id.clone(),
700 parent: parent.to_string(),
701 descendant: member.canister_id.clone(),
702 },
703 );
704 }
705 }
706
707 Ok(())
708}
709
710fn first_unit_ancestor<'a>(
712 member: &'a FleetMember,
713 members_by_id: &BTreeMap<&'a str, &'a FleetMember>,
714 unit_member_ids: &BTreeSet<&'a str>,
715) -> Option<&'a str> {
716 let mut visited = BTreeSet::new();
717 let mut parent = member.parent_canister_id.as_deref();
718 while let Some(parent_id) = parent {
719 if unit_member_ids.contains(parent_id) {
720 return Some(parent_id);
721 }
722 if !visited.insert(parent_id) {
723 return None;
724 }
725 parent = members_by_id
726 .get(parent_id)
727 .and_then(|ancestor| ancestor.parent_canister_id.as_deref());
728 }
729
730 None
731}
732
733const fn validate_manifest_version(version: u16) -> Result<(), ManifestValidationError> {
735 if version == SUPPORTED_MANIFEST_VERSION {
736 Ok(())
737 } else {
738 Err(ManifestValidationError::UnsupportedManifestVersion(version))
739 }
740}
741
742fn validate_nonempty(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
744 if value.trim().is_empty() {
745 Err(ManifestValidationError::EmptyField(field))
746 } else {
747 Ok(())
748 }
749}
750
751fn validate_optional_nonempty(
753 field: &'static str,
754 value: Option<&str>,
755) -> Result<(), ManifestValidationError> {
756 if let Some(value) = value {
757 validate_nonempty(field, value)?;
758 }
759 Ok(())
760}
761
762fn validate_unique_values<F>(
764 field: &'static str,
765 values: &[String],
766 error: F,
767) -> Result<(), ManifestValidationError>
768where
769 F: Fn(&str) -> ManifestValidationError,
770{
771 let mut seen = BTreeSet::new();
772 for value in values {
773 validate_nonempty(field, value)?;
774 if !seen.insert(value.as_str()) {
775 return Err(error(value));
776 }
777 }
778
779 Ok(())
780}
781
782fn validate_principal(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
784 validate_nonempty(field, value)?;
785 Principal::from_str(value)
786 .map(|_| ())
787 .map_err(|_| ManifestValidationError::InvalidPrincipal {
788 field,
789 value: value.to_string(),
790 })
791}
792
793fn validate_optional_principal(
795 field: &'static str,
796 value: Option<&str>,
797) -> Result<(), ManifestValidationError> {
798 if let Some(value) = value {
799 validate_principal(field, value)?;
800 }
801 Ok(())
802}
803
804fn validate_hash(field: &'static str, value: &str) -> Result<(), ManifestValidationError> {
806 const SHA256_HEX_LEN: usize = 64;
807 validate_nonempty(field, value)?;
808 if value.len() == SHA256_HEX_LEN && value.bytes().all(|b| b.is_ascii_hexdigit()) {
809 Ok(())
810 } else {
811 Err(ManifestValidationError::InvalidHash(field))
812 }
813}
814
815fn validate_optional_hash(
817 field: &'static str,
818 value: Option<&str>,
819) -> Result<(), ManifestValidationError> {
820 if let Some(value) = value {
821 validate_hash(field, value)?;
822 }
823 Ok(())
824}
825
826#[cfg(test)]
827mod tests;