1use crate::manifest::{
2 FleetBackupManifest, FleetMember, IdentityMode, ManifestValidationError, SourceSnapshot,
3 VerificationCheck,
4};
5use candid::Principal;
6use serde::{Deserialize, Serialize};
7use std::{
8 collections::{BTreeMap, BTreeSet},
9 str::FromStr,
10};
11use thiserror::Error as ThisError;
12
13#[derive(Clone, Debug, Default, Deserialize, Serialize)]
18pub struct RestoreMapping {
19 pub members: Vec<RestoreMappingEntry>,
20}
21
22impl RestoreMapping {
23 fn target_for(&self, source_canister: &str) -> Option<&str> {
25 self.members
26 .iter()
27 .find(|entry| entry.source_canister == source_canister)
28 .map(|entry| entry.target_canister.as_str())
29 }
30}
31
32#[derive(Clone, Debug, Deserialize, Serialize)]
37pub struct RestoreMappingEntry {
38 pub source_canister: String,
39 pub target_canister: String,
40}
41
42#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
47pub struct RestorePlan {
48 pub backup_id: String,
49 pub source_environment: String,
50 pub source_root_canister: String,
51 pub topology_hash: String,
52 pub member_count: usize,
53 pub identity_summary: RestoreIdentitySummary,
54 pub snapshot_summary: RestoreSnapshotSummary,
55 pub verification_summary: RestoreVerificationSummary,
56 pub readiness_summary: RestoreReadinessSummary,
57 pub operation_summary: RestoreOperationSummary,
58 pub ordering_summary: RestoreOrderingSummary,
59 pub phases: Vec<RestorePhase>,
60}
61
62impl RestorePlan {
63 #[must_use]
65 pub fn ordered_members(&self) -> Vec<&RestorePlanMember> {
66 self.phases
67 .iter()
68 .flat_map(|phase| phase.members.iter())
69 .collect()
70 }
71}
72
73#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
78pub struct RestoreIdentitySummary {
79 pub mapping_supplied: bool,
80 pub all_sources_mapped: bool,
81 pub fixed_members: usize,
82 pub relocatable_members: usize,
83 pub in_place_members: usize,
84 pub mapped_members: usize,
85 pub remapped_members: usize,
86}
87
88#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
93#[expect(
94 clippy::struct_excessive_bools,
95 reason = "restore summaries intentionally expose machine-readable readiness flags"
96)]
97pub struct RestoreSnapshotSummary {
98 pub all_members_have_module_hash: bool,
99 pub all_members_have_wasm_hash: bool,
100 pub all_members_have_code_version: bool,
101 pub all_members_have_checksum: bool,
102 pub members_with_module_hash: usize,
103 pub members_with_wasm_hash: usize,
104 pub members_with_code_version: usize,
105 pub members_with_checksum: usize,
106}
107
108#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
113pub struct RestoreVerificationSummary {
114 pub verification_required: bool,
115 pub all_members_have_checks: bool,
116 pub fleet_checks: usize,
117 pub member_check_groups: usize,
118 pub member_checks: usize,
119 pub members_with_checks: usize,
120 pub total_checks: usize,
121}
122
123#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
128pub struct RestoreReadinessSummary {
129 pub ready: bool,
130 pub reasons: Vec<String>,
131}
132
133#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
138pub struct RestoreOperationSummary {
139 pub planned_snapshot_loads: usize,
140 pub planned_code_reinstalls: usize,
141 pub planned_verification_checks: usize,
142 pub planned_phases: usize,
143}
144
145#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
150pub struct RestoreOrderingSummary {
151 pub phase_count: usize,
152 pub dependency_free_members: usize,
153 pub in_group_parent_edges: usize,
154 pub cross_group_parent_edges: usize,
155}
156
157#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
162pub struct RestorePhase {
163 pub restore_group: u16,
164 pub members: Vec<RestorePlanMember>,
165}
166
167#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
172pub struct RestorePlanMember {
173 pub source_canister: String,
174 pub target_canister: String,
175 pub role: String,
176 pub parent_source_canister: Option<String>,
177 pub parent_target_canister: Option<String>,
178 pub ordering_dependency: Option<RestoreOrderingDependency>,
179 pub phase_order: usize,
180 pub restore_group: u16,
181 pub identity_mode: IdentityMode,
182 pub verification_class: String,
183 pub verification_checks: Vec<VerificationCheck>,
184 pub source_snapshot: SourceSnapshot,
185}
186
187#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
192pub struct RestoreOrderingDependency {
193 pub source_canister: String,
194 pub target_canister: String,
195 pub relationship: RestoreOrderingRelationship,
196}
197
198#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
203#[serde(rename_all = "kebab-case")]
204pub enum RestoreOrderingRelationship {
205 ParentInSameGroup,
206 ParentInEarlierGroup,
207}
208
209pub struct RestorePlanner;
214
215impl RestorePlanner {
216 pub fn plan(
218 manifest: &FleetBackupManifest,
219 mapping: Option<&RestoreMapping>,
220 ) -> Result<RestorePlan, RestorePlanError> {
221 manifest.validate()?;
222 if let Some(mapping) = mapping {
223 validate_mapping(mapping)?;
224 validate_mapping_sources(manifest, mapping)?;
225 }
226
227 let members = resolve_members(manifest, mapping)?;
228 let identity_summary = restore_identity_summary(&members, mapping.is_some());
229 let snapshot_summary = restore_snapshot_summary(&members);
230 let verification_summary = restore_verification_summary(manifest, &members);
231 let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
232 validate_restore_group_dependencies(&members)?;
233 let phases = group_and_order_members(members)?;
234 let ordering_summary = restore_ordering_summary(&phases);
235 let operation_summary =
236 restore_operation_summary(manifest.fleet.members.len(), &verification_summary, &phases);
237
238 Ok(RestorePlan {
239 backup_id: manifest.backup_id.clone(),
240 source_environment: manifest.source.environment.clone(),
241 source_root_canister: manifest.source.root_canister.clone(),
242 topology_hash: manifest.fleet.topology_hash.clone(),
243 member_count: manifest.fleet.members.len(),
244 identity_summary,
245 snapshot_summary,
246 verification_summary,
247 readiness_summary,
248 operation_summary,
249 ordering_summary,
250 phases,
251 })
252 }
253}
254
255#[derive(Debug, ThisError)]
260pub enum RestorePlanError {
261 #[error(transparent)]
262 InvalidManifest(#[from] ManifestValidationError),
263
264 #[error("field {field} must be a valid principal: {value}")]
265 InvalidPrincipal { field: &'static str, value: String },
266
267 #[error("mapping contains duplicate source canister {0}")]
268 DuplicateMappingSource(String),
269
270 #[error("mapping contains duplicate target canister {0}")]
271 DuplicateMappingTarget(String),
272
273 #[error("mapping references unknown source canister {0}")]
274 UnknownMappingSource(String),
275
276 #[error("mapping is missing source canister {0}")]
277 MissingMappingSource(String),
278
279 #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
280 FixedIdentityRemap {
281 source_canister: String,
282 target_canister: String,
283 },
284
285 #[error("restore plan contains duplicate target canister {0}")]
286 DuplicatePlanTarget(String),
287
288 #[error("restore group {0} contains a parent cycle or unresolved dependency")]
289 RestoreOrderCycle(u16),
290
291 #[error(
292 "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
293 )]
294 ParentRestoreGroupAfterChild {
295 child_source_canister: String,
296 parent_source_canister: String,
297 child_restore_group: u16,
298 parent_restore_group: u16,
299 },
300}
301
302fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
304 let mut sources = BTreeSet::new();
305 let mut targets = BTreeSet::new();
306
307 for entry in &mapping.members {
308 validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
309 validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
310
311 if !sources.insert(entry.source_canister.clone()) {
312 return Err(RestorePlanError::DuplicateMappingSource(
313 entry.source_canister.clone(),
314 ));
315 }
316
317 if !targets.insert(entry.target_canister.clone()) {
318 return Err(RestorePlanError::DuplicateMappingTarget(
319 entry.target_canister.clone(),
320 ));
321 }
322 }
323
324 Ok(())
325}
326
327fn validate_mapping_sources(
329 manifest: &FleetBackupManifest,
330 mapping: &RestoreMapping,
331) -> Result<(), RestorePlanError> {
332 let sources = manifest
333 .fleet
334 .members
335 .iter()
336 .map(|member| member.canister_id.as_str())
337 .collect::<BTreeSet<_>>();
338
339 for entry in &mapping.members {
340 if !sources.contains(entry.source_canister.as_str()) {
341 return Err(RestorePlanError::UnknownMappingSource(
342 entry.source_canister.clone(),
343 ));
344 }
345 }
346
347 Ok(())
348}
349
350fn resolve_members(
352 manifest: &FleetBackupManifest,
353 mapping: Option<&RestoreMapping>,
354) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
355 let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
356 let mut targets = BTreeSet::new();
357 let mut source_to_target = BTreeMap::new();
358
359 for member in &manifest.fleet.members {
360 let target = resolve_target(member, mapping)?;
361 if !targets.insert(target.clone()) {
362 return Err(RestorePlanError::DuplicatePlanTarget(target));
363 }
364
365 source_to_target.insert(member.canister_id.clone(), target.clone());
366 plan_members.push(RestorePlanMember {
367 source_canister: member.canister_id.clone(),
368 target_canister: target,
369 role: member.role.clone(),
370 parent_source_canister: member.parent_canister_id.clone(),
371 parent_target_canister: None,
372 ordering_dependency: None,
373 phase_order: 0,
374 restore_group: member.restore_group,
375 identity_mode: member.identity_mode.clone(),
376 verification_class: member.verification_class.clone(),
377 verification_checks: member.verification_checks.clone(),
378 source_snapshot: member.source_snapshot.clone(),
379 });
380 }
381
382 for member in &mut plan_members {
383 member.parent_target_canister = member
384 .parent_source_canister
385 .as_ref()
386 .and_then(|parent| source_to_target.get(parent))
387 .cloned();
388 }
389
390 Ok(plan_members)
391}
392
393fn resolve_target(
395 member: &FleetMember,
396 mapping: Option<&RestoreMapping>,
397) -> Result<String, RestorePlanError> {
398 let target = match mapping {
399 Some(mapping) => mapping
400 .target_for(&member.canister_id)
401 .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
402 .to_string(),
403 None => member.canister_id.clone(),
404 };
405
406 if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
407 return Err(RestorePlanError::FixedIdentityRemap {
408 source_canister: member.canister_id.clone(),
409 target_canister: target,
410 });
411 }
412
413 Ok(target)
414}
415
416fn restore_identity_summary(
418 members: &[RestorePlanMember],
419 mapping_supplied: bool,
420) -> RestoreIdentitySummary {
421 let mut summary = RestoreIdentitySummary {
422 mapping_supplied,
423 all_sources_mapped: false,
424 fixed_members: 0,
425 relocatable_members: 0,
426 in_place_members: 0,
427 mapped_members: 0,
428 remapped_members: 0,
429 };
430
431 for member in members {
432 match member.identity_mode {
433 IdentityMode::Fixed => summary.fixed_members += 1,
434 IdentityMode::Relocatable => summary.relocatable_members += 1,
435 }
436
437 if member.source_canister == member.target_canister {
438 summary.in_place_members += 1;
439 } else {
440 summary.remapped_members += 1;
441 }
442 if mapping_supplied {
443 summary.mapped_members += 1;
444 }
445 }
446
447 summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
448
449 summary
450}
451
452fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
454 let members_with_module_hash = members
455 .iter()
456 .filter(|member| member.source_snapshot.module_hash.is_some())
457 .count();
458 let members_with_wasm_hash = members
459 .iter()
460 .filter(|member| member.source_snapshot.wasm_hash.is_some())
461 .count();
462 let members_with_code_version = members
463 .iter()
464 .filter(|member| member.source_snapshot.code_version.is_some())
465 .count();
466 let members_with_checksum = members
467 .iter()
468 .filter(|member| member.source_snapshot.checksum.is_some())
469 .count();
470
471 RestoreSnapshotSummary {
472 all_members_have_module_hash: members_with_module_hash == members.len(),
473 all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
474 all_members_have_code_version: members_with_code_version == members.len(),
475 all_members_have_checksum: members_with_checksum == members.len(),
476 members_with_module_hash,
477 members_with_wasm_hash,
478 members_with_code_version,
479 members_with_checksum,
480 }
481}
482
483fn restore_readiness_summary(
485 snapshot: &RestoreSnapshotSummary,
486 verification: &RestoreVerificationSummary,
487) -> RestoreReadinessSummary {
488 let mut reasons = Vec::new();
489
490 if !snapshot.all_members_have_module_hash {
491 reasons.push("missing-module-hash".to_string());
492 }
493 if !snapshot.all_members_have_wasm_hash {
494 reasons.push("missing-wasm-hash".to_string());
495 }
496 if !snapshot.all_members_have_code_version {
497 reasons.push("missing-code-version".to_string());
498 }
499 if !snapshot.all_members_have_checksum {
500 reasons.push("missing-snapshot-checksum".to_string());
501 }
502 if !verification.all_members_have_checks {
503 reasons.push("missing-verification-checks".to_string());
504 }
505
506 RestoreReadinessSummary {
507 ready: reasons.is_empty(),
508 reasons,
509 }
510}
511
512fn restore_verification_summary(
514 manifest: &FleetBackupManifest,
515 members: &[RestorePlanMember],
516) -> RestoreVerificationSummary {
517 let fleet_checks = manifest.verification.fleet_checks.len();
518 let member_check_groups = manifest.verification.member_checks.len();
519 let role_check_counts = manifest
520 .verification
521 .member_checks
522 .iter()
523 .map(|group| (group.role.as_str(), group.checks.len()))
524 .collect::<BTreeMap<_, _>>();
525 let inline_member_checks = members
526 .iter()
527 .map(|member| member.verification_checks.len())
528 .sum::<usize>();
529 let role_member_checks = members
530 .iter()
531 .map(|member| {
532 role_check_counts
533 .get(member.role.as_str())
534 .copied()
535 .unwrap_or(0)
536 })
537 .sum::<usize>();
538 let member_checks = inline_member_checks + role_member_checks;
539 let members_with_checks = members
540 .iter()
541 .filter(|member| {
542 !member.verification_checks.is_empty()
543 || role_check_counts.contains_key(member.role.as_str())
544 })
545 .count();
546
547 RestoreVerificationSummary {
548 verification_required: true,
549 all_members_have_checks: members_with_checks == members.len(),
550 fleet_checks,
551 member_check_groups,
552 member_checks,
553 members_with_checks,
554 total_checks: fleet_checks + member_checks,
555 }
556}
557
558const fn restore_operation_summary(
560 member_count: usize,
561 verification_summary: &RestoreVerificationSummary,
562 phases: &[RestorePhase],
563) -> RestoreOperationSummary {
564 RestoreOperationSummary {
565 planned_snapshot_loads: member_count,
566 planned_code_reinstalls: member_count,
567 planned_verification_checks: verification_summary.total_checks,
568 planned_phases: phases.len(),
569 }
570}
571
572fn validate_restore_group_dependencies(
574 members: &[RestorePlanMember],
575) -> Result<(), RestorePlanError> {
576 let groups_by_source = members
577 .iter()
578 .map(|member| (member.source_canister.as_str(), member.restore_group))
579 .collect::<BTreeMap<_, _>>();
580
581 for member in members {
582 let Some(parent) = &member.parent_source_canister else {
583 continue;
584 };
585 let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
586 continue;
587 };
588
589 if *parent_group > member.restore_group {
590 return Err(RestorePlanError::ParentRestoreGroupAfterChild {
591 child_source_canister: member.source_canister.clone(),
592 parent_source_canister: parent.clone(),
593 child_restore_group: member.restore_group,
594 parent_restore_group: *parent_group,
595 });
596 }
597 }
598
599 Ok(())
600}
601
602fn group_and_order_members(
604 members: Vec<RestorePlanMember>,
605) -> Result<Vec<RestorePhase>, RestorePlanError> {
606 let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
607 for member in members {
608 groups.entry(member.restore_group).or_default().push(member);
609 }
610
611 groups
612 .into_iter()
613 .map(|(restore_group, members)| {
614 let members = order_group(restore_group, members)?;
615 Ok(RestorePhase {
616 restore_group,
617 members,
618 })
619 })
620 .collect()
621}
622
623fn order_group(
625 restore_group: u16,
626 members: Vec<RestorePlanMember>,
627) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
628 let mut remaining = members;
629 let group_sources = remaining
630 .iter()
631 .map(|member| member.source_canister.clone())
632 .collect::<BTreeSet<_>>();
633 let mut emitted = BTreeSet::new();
634 let mut ordered = Vec::with_capacity(remaining.len());
635
636 while !remaining.is_empty() {
637 let Some(index) = remaining
638 .iter()
639 .position(|member| parent_satisfied(member, &group_sources, &emitted))
640 else {
641 return Err(RestorePlanError::RestoreOrderCycle(restore_group));
642 };
643
644 let mut member = remaining.remove(index);
645 member.phase_order = ordered.len();
646 member.ordering_dependency = ordering_dependency(&member, &group_sources);
647 emitted.insert(member.source_canister.clone());
648 ordered.push(member);
649 }
650
651 Ok(ordered)
652}
653
654fn ordering_dependency(
656 member: &RestorePlanMember,
657 group_sources: &BTreeSet<String>,
658) -> Option<RestoreOrderingDependency> {
659 let parent_source = member.parent_source_canister.as_ref()?;
660 let parent_target = member.parent_target_canister.as_ref()?;
661 let relationship = if group_sources.contains(parent_source) {
662 RestoreOrderingRelationship::ParentInSameGroup
663 } else {
664 RestoreOrderingRelationship::ParentInEarlierGroup
665 };
666
667 Some(RestoreOrderingDependency {
668 source_canister: parent_source.clone(),
669 target_canister: parent_target.clone(),
670 relationship,
671 })
672}
673
674fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
676 let mut summary = RestoreOrderingSummary {
677 phase_count: phases.len(),
678 dependency_free_members: 0,
679 in_group_parent_edges: 0,
680 cross_group_parent_edges: 0,
681 };
682
683 for member in phases.iter().flat_map(|phase| phase.members.iter()) {
684 match &member.ordering_dependency {
685 Some(dependency)
686 if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
687 {
688 summary.in_group_parent_edges += 1;
689 }
690 Some(dependency)
691 if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
692 {
693 summary.cross_group_parent_edges += 1;
694 }
695 Some(_) => {}
696 None => summary.dependency_free_members += 1,
697 }
698 }
699
700 summary
701}
702
703fn parent_satisfied(
705 member: &RestorePlanMember,
706 group_sources: &BTreeSet<String>,
707 emitted: &BTreeSet<String>,
708) -> bool {
709 match &member.parent_source_canister {
710 Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
711 _ => true,
712 }
713}
714
715fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
717 Principal::from_str(value)
718 .map(|_| ())
719 .map_err(|_| RestorePlanError::InvalidPrincipal {
720 field,
721 value: value.to_string(),
722 })
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728 use crate::manifest::{
729 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetSection,
730 MemberVerificationChecks, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
731 VerificationPlan,
732 };
733
734 const ROOT: &str = "aaaaa-aa";
735 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
736 const CHILD_TWO: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";
737 const TARGET: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
738 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
739
740 fn valid_manifest(identity_mode: IdentityMode) -> FleetBackupManifest {
742 FleetBackupManifest {
743 manifest_version: 1,
744 backup_id: "fbk_test_001".to_string(),
745 created_at: "2026-04-10T12:00:00Z".to_string(),
746 tool: ToolMetadata {
747 name: "canic".to_string(),
748 version: "v1".to_string(),
749 },
750 source: SourceMetadata {
751 environment: "local".to_string(),
752 root_canister: ROOT.to_string(),
753 },
754 consistency: ConsistencySection {
755 mode: ConsistencyMode::CrashConsistent,
756 backup_units: vec![BackupUnit {
757 unit_id: "whole-fleet".to_string(),
758 kind: BackupUnitKind::WholeFleet,
759 roles: vec!["root".to_string(), "app".to_string()],
760 consistency_reason: None,
761 dependency_closure: Vec::new(),
762 topology_validation: "subtree-closed".to_string(),
763 quiescence_strategy: None,
764 }],
765 },
766 fleet: FleetSection {
767 topology_hash_algorithm: "sha256".to_string(),
768 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
769 discovery_topology_hash: HASH.to_string(),
770 pre_snapshot_topology_hash: HASH.to_string(),
771 topology_hash: HASH.to_string(),
772 members: vec![
773 fleet_member("app", CHILD, Some(ROOT), identity_mode, 1),
774 fleet_member("root", ROOT, None, IdentityMode::Fixed, 1),
775 ],
776 },
777 verification: VerificationPlan {
778 fleet_checks: Vec::new(),
779 member_checks: Vec::new(),
780 },
781 }
782 }
783
784 fn fleet_member(
786 role: &str,
787 canister_id: &str,
788 parent_canister_id: Option<&str>,
789 identity_mode: IdentityMode,
790 restore_group: u16,
791 ) -> FleetMember {
792 FleetMember {
793 role: role.to_string(),
794 canister_id: canister_id.to_string(),
795 parent_canister_id: parent_canister_id.map(str::to_string),
796 subnet_canister_id: None,
797 controller_hint: Some(ROOT.to_string()),
798 identity_mode,
799 restore_group,
800 verification_class: "basic".to_string(),
801 verification_checks: vec![VerificationCheck {
802 kind: "call".to_string(),
803 method: Some("canic_ready".to_string()),
804 roles: Vec::new(),
805 }],
806 source_snapshot: SourceSnapshot {
807 snapshot_id: format!("snap-{role}"),
808 module_hash: Some(HASH.to_string()),
809 wasm_hash: Some(HASH.to_string()),
810 code_version: Some("v0.30.0".to_string()),
811 artifact_path: format!("artifacts/{role}"),
812 checksum_algorithm: "sha256".to_string(),
813 checksum: Some(HASH.to_string()),
814 },
815 }
816 }
817
818 #[test]
820 fn in_place_plan_orders_parent_before_child() {
821 let manifest = valid_manifest(IdentityMode::Relocatable);
822
823 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
824 let ordered = plan.ordered_members();
825
826 assert_eq!(plan.backup_id, "fbk_test_001");
827 assert_eq!(plan.source_environment, "local");
828 assert_eq!(plan.source_root_canister, ROOT);
829 assert_eq!(plan.topology_hash, HASH);
830 assert_eq!(plan.member_count, 2);
831 assert_eq!(plan.identity_summary.fixed_members, 1);
832 assert_eq!(plan.identity_summary.relocatable_members, 1);
833 assert_eq!(plan.identity_summary.in_place_members, 2);
834 assert_eq!(plan.identity_summary.mapped_members, 0);
835 assert_eq!(plan.identity_summary.remapped_members, 0);
836 assert!(plan.verification_summary.verification_required);
837 assert!(plan.verification_summary.all_members_have_checks);
838 assert!(plan.readiness_summary.ready);
839 assert!(plan.readiness_summary.reasons.is_empty());
840 assert_eq!(plan.verification_summary.fleet_checks, 0);
841 assert_eq!(plan.verification_summary.member_check_groups, 0);
842 assert_eq!(plan.verification_summary.member_checks, 2);
843 assert_eq!(plan.verification_summary.members_with_checks, 2);
844 assert_eq!(plan.verification_summary.total_checks, 2);
845 assert_eq!(plan.ordering_summary.phase_count, 1);
846 assert_eq!(plan.ordering_summary.dependency_free_members, 1);
847 assert_eq!(plan.ordering_summary.in_group_parent_edges, 1);
848 assert_eq!(plan.ordering_summary.cross_group_parent_edges, 0);
849 assert_eq!(ordered[0].phase_order, 0);
850 assert_eq!(ordered[1].phase_order, 1);
851 assert_eq!(ordered[0].source_canister, ROOT);
852 assert_eq!(ordered[1].source_canister, CHILD);
853 assert_eq!(
854 ordered[1].ordering_dependency,
855 Some(RestoreOrderingDependency {
856 source_canister: ROOT.to_string(),
857 target_canister: ROOT.to_string(),
858 relationship: RestoreOrderingRelationship::ParentInSameGroup,
859 })
860 );
861 }
862
863 #[test]
865 fn plan_reports_parent_dependency_from_earlier_group() {
866 let mut manifest = valid_manifest(IdentityMode::Relocatable);
867 manifest.fleet.members[0].restore_group = 2;
868 manifest.fleet.members[1].restore_group = 1;
869
870 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
871 let ordered = plan.ordered_members();
872
873 assert_eq!(plan.phases.len(), 2);
874 assert_eq!(plan.ordering_summary.phase_count, 2);
875 assert_eq!(plan.ordering_summary.dependency_free_members, 1);
876 assert_eq!(plan.ordering_summary.in_group_parent_edges, 0);
877 assert_eq!(plan.ordering_summary.cross_group_parent_edges, 1);
878 assert_eq!(ordered[0].source_canister, ROOT);
879 assert_eq!(ordered[1].source_canister, CHILD);
880 assert_eq!(
881 ordered[1].ordering_dependency,
882 Some(RestoreOrderingDependency {
883 source_canister: ROOT.to_string(),
884 target_canister: ROOT.to_string(),
885 relationship: RestoreOrderingRelationship::ParentInEarlierGroup,
886 })
887 );
888 }
889
890 #[test]
892 fn plan_rejects_parent_in_later_restore_group() {
893 let mut manifest = valid_manifest(IdentityMode::Relocatable);
894 manifest.fleet.members[0].restore_group = 1;
895 manifest.fleet.members[1].restore_group = 2;
896
897 let err = RestorePlanner::plan(&manifest, None)
898 .expect_err("parent-after-child group ordering should fail");
899
900 assert!(matches!(
901 err,
902 RestorePlanError::ParentRestoreGroupAfterChild { .. }
903 ));
904 }
905
906 #[test]
908 fn fixed_identity_member_cannot_be_remapped() {
909 let manifest = valid_manifest(IdentityMode::Fixed);
910 let mapping = RestoreMapping {
911 members: vec![
912 RestoreMappingEntry {
913 source_canister: ROOT.to_string(),
914 target_canister: ROOT.to_string(),
915 },
916 RestoreMappingEntry {
917 source_canister: CHILD.to_string(),
918 target_canister: TARGET.to_string(),
919 },
920 ],
921 };
922
923 let err = RestorePlanner::plan(&manifest, Some(&mapping))
924 .expect_err("fixed member remap should fail");
925
926 assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
927 }
928
929 #[test]
931 fn relocatable_member_can_be_mapped() {
932 let manifest = valid_manifest(IdentityMode::Relocatable);
933 let mapping = RestoreMapping {
934 members: vec![
935 RestoreMappingEntry {
936 source_canister: ROOT.to_string(),
937 target_canister: ROOT.to_string(),
938 },
939 RestoreMappingEntry {
940 source_canister: CHILD.to_string(),
941 target_canister: TARGET.to_string(),
942 },
943 ],
944 };
945
946 let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
947 let child = plan
948 .ordered_members()
949 .into_iter()
950 .find(|member| member.source_canister == CHILD)
951 .expect("child member should be planned");
952
953 assert_eq!(plan.identity_summary.fixed_members, 1);
954 assert_eq!(plan.identity_summary.relocatable_members, 1);
955 assert_eq!(plan.identity_summary.in_place_members, 1);
956 assert_eq!(plan.identity_summary.mapped_members, 2);
957 assert_eq!(plan.identity_summary.remapped_members, 1);
958 assert_eq!(child.target_canister, TARGET);
959 assert_eq!(child.parent_target_canister, Some(ROOT.to_string()));
960 }
961
962 #[test]
964 fn plan_members_include_snapshot_and_verification_metadata() {
965 let manifest = valid_manifest(IdentityMode::Relocatable);
966
967 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
968 let root = plan
969 .ordered_members()
970 .into_iter()
971 .find(|member| member.source_canister == ROOT)
972 .expect("root member should be planned");
973
974 assert_eq!(root.identity_mode, IdentityMode::Fixed);
975 assert_eq!(root.verification_class, "basic");
976 assert_eq!(root.verification_checks[0].kind, "call");
977 assert_eq!(root.source_snapshot.snapshot_id, "snap-root");
978 assert_eq!(root.source_snapshot.artifact_path, "artifacts/root");
979 }
980
981 #[test]
983 fn plan_includes_mapping_summary() {
984 let manifest = valid_manifest(IdentityMode::Relocatable);
985 let in_place = RestorePlanner::plan(&manifest, None).expect("plan should build");
986
987 assert!(!in_place.identity_summary.mapping_supplied);
988 assert!(!in_place.identity_summary.all_sources_mapped);
989 assert_eq!(in_place.identity_summary.mapped_members, 0);
990
991 let mapping = RestoreMapping {
992 members: vec![
993 RestoreMappingEntry {
994 source_canister: ROOT.to_string(),
995 target_canister: ROOT.to_string(),
996 },
997 RestoreMappingEntry {
998 source_canister: CHILD.to_string(),
999 target_canister: TARGET.to_string(),
1000 },
1001 ],
1002 };
1003 let mapped = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
1004
1005 assert!(mapped.identity_summary.mapping_supplied);
1006 assert!(mapped.identity_summary.all_sources_mapped);
1007 assert_eq!(mapped.identity_summary.mapped_members, 2);
1008 assert_eq!(mapped.identity_summary.remapped_members, 1);
1009 }
1010
1011 #[test]
1013 fn plan_includes_snapshot_summary() {
1014 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1015 manifest.fleet.members[1].source_snapshot.module_hash = None;
1016 manifest.fleet.members[1].source_snapshot.wasm_hash = None;
1017 manifest.fleet.members[1].source_snapshot.checksum = None;
1018
1019 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1020
1021 assert!(!plan.snapshot_summary.all_members_have_module_hash);
1022 assert!(!plan.snapshot_summary.all_members_have_wasm_hash);
1023 assert!(plan.snapshot_summary.all_members_have_code_version);
1024 assert!(!plan.snapshot_summary.all_members_have_checksum);
1025 assert_eq!(plan.snapshot_summary.members_with_module_hash, 1);
1026 assert_eq!(plan.snapshot_summary.members_with_wasm_hash, 1);
1027 assert_eq!(plan.snapshot_summary.members_with_code_version, 2);
1028 assert_eq!(plan.snapshot_summary.members_with_checksum, 1);
1029 assert!(!plan.readiness_summary.ready);
1030 assert_eq!(
1031 plan.readiness_summary.reasons,
1032 [
1033 "missing-module-hash",
1034 "missing-wasm-hash",
1035 "missing-snapshot-checksum"
1036 ]
1037 );
1038 }
1039
1040 #[test]
1042 fn plan_includes_verification_summary() {
1043 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1044 manifest.verification.fleet_checks.push(VerificationCheck {
1045 kind: "fleet-ready".to_string(),
1046 method: None,
1047 roles: Vec::new(),
1048 });
1049 manifest
1050 .verification
1051 .member_checks
1052 .push(MemberVerificationChecks {
1053 role: "app".to_string(),
1054 checks: vec![VerificationCheck {
1055 kind: "app-ready".to_string(),
1056 method: Some("ready".to_string()),
1057 roles: Vec::new(),
1058 }],
1059 });
1060
1061 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1062
1063 assert!(plan.verification_summary.verification_required);
1064 assert!(plan.verification_summary.all_members_have_checks);
1065 assert_eq!(plan.verification_summary.fleet_checks, 1);
1066 assert_eq!(plan.verification_summary.member_check_groups, 1);
1067 assert_eq!(plan.verification_summary.member_checks, 3);
1068 assert_eq!(plan.verification_summary.members_with_checks, 2);
1069 assert_eq!(plan.verification_summary.total_checks, 4);
1070 }
1071
1072 #[test]
1074 fn plan_includes_operation_summary() {
1075 let manifest = valid_manifest(IdentityMode::Relocatable);
1076
1077 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1078
1079 assert_eq!(plan.operation_summary.planned_snapshot_loads, 2);
1080 assert_eq!(plan.operation_summary.planned_code_reinstalls, 2);
1081 assert_eq!(plan.operation_summary.planned_verification_checks, 2);
1082 assert_eq!(plan.operation_summary.planned_phases, 1);
1083 }
1084
1085 #[test]
1087 fn plan_expands_role_verification_checks_per_matching_member() {
1088 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1089 manifest.fleet.members.push(fleet_member(
1090 "app",
1091 CHILD_TWO,
1092 Some(ROOT),
1093 IdentityMode::Relocatable,
1094 1,
1095 ));
1096 manifest
1097 .verification
1098 .member_checks
1099 .push(MemberVerificationChecks {
1100 role: "app".to_string(),
1101 checks: vec![VerificationCheck {
1102 kind: "app-ready".to_string(),
1103 method: Some("ready".to_string()),
1104 roles: Vec::new(),
1105 }],
1106 });
1107
1108 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1109
1110 assert_eq!(plan.verification_summary.fleet_checks, 0);
1111 assert_eq!(plan.verification_summary.member_check_groups, 1);
1112 assert_eq!(plan.verification_summary.member_checks, 5);
1113 assert_eq!(plan.verification_summary.members_with_checks, 3);
1114 assert_eq!(plan.verification_summary.total_checks, 5);
1115 }
1116
1117 #[test]
1119 fn mapped_restore_requires_complete_mapping() {
1120 let manifest = valid_manifest(IdentityMode::Relocatable);
1121 let mapping = RestoreMapping {
1122 members: vec![RestoreMappingEntry {
1123 source_canister: ROOT.to_string(),
1124 target_canister: ROOT.to_string(),
1125 }],
1126 };
1127
1128 let err = RestorePlanner::plan(&manifest, Some(&mapping))
1129 .expect_err("incomplete mapping should fail");
1130
1131 assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
1132 }
1133
1134 #[test]
1136 fn mapped_restore_rejects_unknown_mapping_sources() {
1137 let manifest = valid_manifest(IdentityMode::Relocatable);
1138 let unknown = "rdmx6-jaaaa-aaaaa-aaadq-cai";
1139 let mapping = RestoreMapping {
1140 members: vec![
1141 RestoreMappingEntry {
1142 source_canister: ROOT.to_string(),
1143 target_canister: ROOT.to_string(),
1144 },
1145 RestoreMappingEntry {
1146 source_canister: CHILD.to_string(),
1147 target_canister: TARGET.to_string(),
1148 },
1149 RestoreMappingEntry {
1150 source_canister: unknown.to_string(),
1151 target_canister: unknown.to_string(),
1152 },
1153 ],
1154 };
1155
1156 let err = RestorePlanner::plan(&manifest, Some(&mapping))
1157 .expect_err("unknown mapping source should fail");
1158
1159 assert!(matches!(err, RestorePlanError::UnknownMappingSource(_)));
1160 }
1161
1162 #[test]
1164 fn duplicate_mapping_targets_fail_validation() {
1165 let manifest = valid_manifest(IdentityMode::Relocatable);
1166 let mapping = RestoreMapping {
1167 members: vec![
1168 RestoreMappingEntry {
1169 source_canister: ROOT.to_string(),
1170 target_canister: ROOT.to_string(),
1171 },
1172 RestoreMappingEntry {
1173 source_canister: CHILD.to_string(),
1174 target_canister: ROOT.to_string(),
1175 },
1176 ],
1177 };
1178
1179 let err = RestorePlanner::plan(&manifest, Some(&mapping))
1180 .expect_err("duplicate targets should fail");
1181
1182 assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
1183 }
1184}