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 verification_summary: RestoreVerificationSummary,
55 pub ordering_summary: RestoreOrderingSummary,
56 pub phases: Vec<RestorePhase>,
57}
58
59impl RestorePlan {
60 #[must_use]
62 pub fn ordered_members(&self) -> Vec<&RestorePlanMember> {
63 self.phases
64 .iter()
65 .flat_map(|phase| phase.members.iter())
66 .collect()
67 }
68}
69
70#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
75pub struct RestoreIdentitySummary {
76 pub fixed_members: usize,
77 pub relocatable_members: usize,
78 pub in_place_members: usize,
79 pub mapped_members: usize,
80 pub remapped_members: usize,
81}
82
83#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
88pub struct RestoreVerificationSummary {
89 pub fleet_checks: usize,
90 pub member_check_groups: usize,
91 pub member_checks: usize,
92 pub members_with_checks: usize,
93 pub total_checks: usize,
94}
95
96#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
101pub struct RestoreOrderingSummary {
102 pub phase_count: usize,
103 pub dependency_free_members: usize,
104 pub in_group_parent_edges: usize,
105 pub cross_group_parent_edges: usize,
106}
107
108#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
113pub struct RestorePhase {
114 pub restore_group: u16,
115 pub members: Vec<RestorePlanMember>,
116}
117
118#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
123pub struct RestorePlanMember {
124 pub source_canister: String,
125 pub target_canister: String,
126 pub role: String,
127 pub parent_source_canister: Option<String>,
128 pub parent_target_canister: Option<String>,
129 pub ordering_dependency: Option<RestoreOrderingDependency>,
130 pub phase_order: usize,
131 pub restore_group: u16,
132 pub identity_mode: IdentityMode,
133 pub verification_class: String,
134 pub verification_checks: Vec<VerificationCheck>,
135 pub source_snapshot: SourceSnapshot,
136}
137
138#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
143pub struct RestoreOrderingDependency {
144 pub source_canister: String,
145 pub target_canister: String,
146 pub relationship: RestoreOrderingRelationship,
147}
148
149#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
154#[serde(rename_all = "kebab-case")]
155pub enum RestoreOrderingRelationship {
156 ParentInSameGroup,
157 ParentInEarlierGroup,
158}
159
160pub struct RestorePlanner;
165
166impl RestorePlanner {
167 pub fn plan(
169 manifest: &FleetBackupManifest,
170 mapping: Option<&RestoreMapping>,
171 ) -> Result<RestorePlan, RestorePlanError> {
172 manifest.validate()?;
173 if let Some(mapping) = mapping {
174 validate_mapping(mapping)?;
175 validate_mapping_sources(manifest, mapping)?;
176 }
177
178 let members = resolve_members(manifest, mapping)?;
179 let identity_summary = restore_identity_summary(&members, mapping.is_some());
180 let verification_summary = restore_verification_summary(manifest, &members);
181 validate_restore_group_dependencies(&members)?;
182 let phases = group_and_order_members(members)?;
183 let ordering_summary = restore_ordering_summary(&phases);
184
185 Ok(RestorePlan {
186 backup_id: manifest.backup_id.clone(),
187 source_environment: manifest.source.environment.clone(),
188 source_root_canister: manifest.source.root_canister.clone(),
189 topology_hash: manifest.fleet.topology_hash.clone(),
190 member_count: manifest.fleet.members.len(),
191 identity_summary,
192 verification_summary,
193 ordering_summary,
194 phases,
195 })
196 }
197}
198
199#[derive(Debug, ThisError)]
204pub enum RestorePlanError {
205 #[error(transparent)]
206 InvalidManifest(#[from] ManifestValidationError),
207
208 #[error("field {field} must be a valid principal: {value}")]
209 InvalidPrincipal { field: &'static str, value: String },
210
211 #[error("mapping contains duplicate source canister {0}")]
212 DuplicateMappingSource(String),
213
214 #[error("mapping contains duplicate target canister {0}")]
215 DuplicateMappingTarget(String),
216
217 #[error("mapping references unknown source canister {0}")]
218 UnknownMappingSource(String),
219
220 #[error("mapping is missing source canister {0}")]
221 MissingMappingSource(String),
222
223 #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
224 FixedIdentityRemap {
225 source_canister: String,
226 target_canister: String,
227 },
228
229 #[error("restore plan contains duplicate target canister {0}")]
230 DuplicatePlanTarget(String),
231
232 #[error("restore group {0} contains a parent cycle or unresolved dependency")]
233 RestoreOrderCycle(u16),
234
235 #[error(
236 "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
237 )]
238 ParentRestoreGroupAfterChild {
239 child_source_canister: String,
240 parent_source_canister: String,
241 child_restore_group: u16,
242 parent_restore_group: u16,
243 },
244}
245
246fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
248 let mut sources = BTreeSet::new();
249 let mut targets = BTreeSet::new();
250
251 for entry in &mapping.members {
252 validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
253 validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
254
255 if !sources.insert(entry.source_canister.clone()) {
256 return Err(RestorePlanError::DuplicateMappingSource(
257 entry.source_canister.clone(),
258 ));
259 }
260
261 if !targets.insert(entry.target_canister.clone()) {
262 return Err(RestorePlanError::DuplicateMappingTarget(
263 entry.target_canister.clone(),
264 ));
265 }
266 }
267
268 Ok(())
269}
270
271fn validate_mapping_sources(
273 manifest: &FleetBackupManifest,
274 mapping: &RestoreMapping,
275) -> Result<(), RestorePlanError> {
276 let sources = manifest
277 .fleet
278 .members
279 .iter()
280 .map(|member| member.canister_id.as_str())
281 .collect::<BTreeSet<_>>();
282
283 for entry in &mapping.members {
284 if !sources.contains(entry.source_canister.as_str()) {
285 return Err(RestorePlanError::UnknownMappingSource(
286 entry.source_canister.clone(),
287 ));
288 }
289 }
290
291 Ok(())
292}
293
294fn resolve_members(
296 manifest: &FleetBackupManifest,
297 mapping: Option<&RestoreMapping>,
298) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
299 let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
300 let mut targets = BTreeSet::new();
301 let mut source_to_target = BTreeMap::new();
302
303 for member in &manifest.fleet.members {
304 let target = resolve_target(member, mapping)?;
305 if !targets.insert(target.clone()) {
306 return Err(RestorePlanError::DuplicatePlanTarget(target));
307 }
308
309 source_to_target.insert(member.canister_id.clone(), target.clone());
310 plan_members.push(RestorePlanMember {
311 source_canister: member.canister_id.clone(),
312 target_canister: target,
313 role: member.role.clone(),
314 parent_source_canister: member.parent_canister_id.clone(),
315 parent_target_canister: None,
316 ordering_dependency: None,
317 phase_order: 0,
318 restore_group: member.restore_group,
319 identity_mode: member.identity_mode.clone(),
320 verification_class: member.verification_class.clone(),
321 verification_checks: member.verification_checks.clone(),
322 source_snapshot: member.source_snapshot.clone(),
323 });
324 }
325
326 for member in &mut plan_members {
327 member.parent_target_canister = member
328 .parent_source_canister
329 .as_ref()
330 .and_then(|parent| source_to_target.get(parent))
331 .cloned();
332 }
333
334 Ok(plan_members)
335}
336
337fn resolve_target(
339 member: &FleetMember,
340 mapping: Option<&RestoreMapping>,
341) -> Result<String, RestorePlanError> {
342 let target = match mapping {
343 Some(mapping) => mapping
344 .target_for(&member.canister_id)
345 .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
346 .to_string(),
347 None => member.canister_id.clone(),
348 };
349
350 if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
351 return Err(RestorePlanError::FixedIdentityRemap {
352 source_canister: member.canister_id.clone(),
353 target_canister: target,
354 });
355 }
356
357 Ok(target)
358}
359
360fn restore_identity_summary(
362 members: &[RestorePlanMember],
363 mapping_supplied: bool,
364) -> RestoreIdentitySummary {
365 let mut summary = RestoreIdentitySummary {
366 fixed_members: 0,
367 relocatable_members: 0,
368 in_place_members: 0,
369 mapped_members: 0,
370 remapped_members: 0,
371 };
372
373 for member in members {
374 match member.identity_mode {
375 IdentityMode::Fixed => summary.fixed_members += 1,
376 IdentityMode::Relocatable => summary.relocatable_members += 1,
377 }
378
379 if member.source_canister == member.target_canister {
380 summary.in_place_members += 1;
381 } else {
382 summary.remapped_members += 1;
383 }
384 if mapping_supplied {
385 summary.mapped_members += 1;
386 }
387 }
388
389 summary
390}
391
392fn restore_verification_summary(
394 manifest: &FleetBackupManifest,
395 members: &[RestorePlanMember],
396) -> RestoreVerificationSummary {
397 let fleet_checks = manifest.verification.fleet_checks.len();
398 let member_check_groups = manifest.verification.member_checks.len();
399 let role_check_counts = manifest
400 .verification
401 .member_checks
402 .iter()
403 .map(|group| (group.role.as_str(), group.checks.len()))
404 .collect::<BTreeMap<_, _>>();
405 let inline_member_checks = members
406 .iter()
407 .map(|member| member.verification_checks.len())
408 .sum::<usize>();
409 let role_member_checks = members
410 .iter()
411 .map(|member| {
412 role_check_counts
413 .get(member.role.as_str())
414 .copied()
415 .unwrap_or(0)
416 })
417 .sum::<usize>();
418 let member_checks = inline_member_checks + role_member_checks;
419 let members_with_checks = members
420 .iter()
421 .filter(|member| {
422 !member.verification_checks.is_empty()
423 || role_check_counts.contains_key(member.role.as_str())
424 })
425 .count();
426
427 RestoreVerificationSummary {
428 fleet_checks,
429 member_check_groups,
430 member_checks,
431 members_with_checks,
432 total_checks: fleet_checks + member_checks,
433 }
434}
435
436fn validate_restore_group_dependencies(
438 members: &[RestorePlanMember],
439) -> Result<(), RestorePlanError> {
440 let groups_by_source = members
441 .iter()
442 .map(|member| (member.source_canister.as_str(), member.restore_group))
443 .collect::<BTreeMap<_, _>>();
444
445 for member in members {
446 let Some(parent) = &member.parent_source_canister else {
447 continue;
448 };
449 let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
450 continue;
451 };
452
453 if *parent_group > member.restore_group {
454 return Err(RestorePlanError::ParentRestoreGroupAfterChild {
455 child_source_canister: member.source_canister.clone(),
456 parent_source_canister: parent.clone(),
457 child_restore_group: member.restore_group,
458 parent_restore_group: *parent_group,
459 });
460 }
461 }
462
463 Ok(())
464}
465
466fn group_and_order_members(
468 members: Vec<RestorePlanMember>,
469) -> Result<Vec<RestorePhase>, RestorePlanError> {
470 let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
471 for member in members {
472 groups.entry(member.restore_group).or_default().push(member);
473 }
474
475 groups
476 .into_iter()
477 .map(|(restore_group, members)| {
478 let members = order_group(restore_group, members)?;
479 Ok(RestorePhase {
480 restore_group,
481 members,
482 })
483 })
484 .collect()
485}
486
487fn order_group(
489 restore_group: u16,
490 members: Vec<RestorePlanMember>,
491) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
492 let mut remaining = members;
493 let group_sources = remaining
494 .iter()
495 .map(|member| member.source_canister.clone())
496 .collect::<BTreeSet<_>>();
497 let mut emitted = BTreeSet::new();
498 let mut ordered = Vec::with_capacity(remaining.len());
499
500 while !remaining.is_empty() {
501 let Some(index) = remaining
502 .iter()
503 .position(|member| parent_satisfied(member, &group_sources, &emitted))
504 else {
505 return Err(RestorePlanError::RestoreOrderCycle(restore_group));
506 };
507
508 let mut member = remaining.remove(index);
509 member.phase_order = ordered.len();
510 member.ordering_dependency = ordering_dependency(&member, &group_sources);
511 emitted.insert(member.source_canister.clone());
512 ordered.push(member);
513 }
514
515 Ok(ordered)
516}
517
518fn ordering_dependency(
520 member: &RestorePlanMember,
521 group_sources: &BTreeSet<String>,
522) -> Option<RestoreOrderingDependency> {
523 let parent_source = member.parent_source_canister.as_ref()?;
524 let parent_target = member.parent_target_canister.as_ref()?;
525 let relationship = if group_sources.contains(parent_source) {
526 RestoreOrderingRelationship::ParentInSameGroup
527 } else {
528 RestoreOrderingRelationship::ParentInEarlierGroup
529 };
530
531 Some(RestoreOrderingDependency {
532 source_canister: parent_source.clone(),
533 target_canister: parent_target.clone(),
534 relationship,
535 })
536}
537
538fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
540 let mut summary = RestoreOrderingSummary {
541 phase_count: phases.len(),
542 dependency_free_members: 0,
543 in_group_parent_edges: 0,
544 cross_group_parent_edges: 0,
545 };
546
547 for member in phases.iter().flat_map(|phase| phase.members.iter()) {
548 match &member.ordering_dependency {
549 Some(dependency)
550 if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
551 {
552 summary.in_group_parent_edges += 1;
553 }
554 Some(dependency)
555 if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
556 {
557 summary.cross_group_parent_edges += 1;
558 }
559 Some(_) => {}
560 None => summary.dependency_free_members += 1,
561 }
562 }
563
564 summary
565}
566
567fn parent_satisfied(
569 member: &RestorePlanMember,
570 group_sources: &BTreeSet<String>,
571 emitted: &BTreeSet<String>,
572) -> bool {
573 match &member.parent_source_canister {
574 Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
575 _ => true,
576 }
577}
578
579fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
581 Principal::from_str(value)
582 .map(|_| ())
583 .map_err(|_| RestorePlanError::InvalidPrincipal {
584 field,
585 value: value.to_string(),
586 })
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use crate::manifest::{
593 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetSection,
594 MemberVerificationChecks, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
595 VerificationPlan,
596 };
597
598 const ROOT: &str = "aaaaa-aa";
599 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
600 const CHILD_TWO: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";
601 const TARGET: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
602 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
603
604 fn valid_manifest(identity_mode: IdentityMode) -> FleetBackupManifest {
606 FleetBackupManifest {
607 manifest_version: 1,
608 backup_id: "fbk_test_001".to_string(),
609 created_at: "2026-04-10T12:00:00Z".to_string(),
610 tool: ToolMetadata {
611 name: "canic".to_string(),
612 version: "v1".to_string(),
613 },
614 source: SourceMetadata {
615 environment: "local".to_string(),
616 root_canister: ROOT.to_string(),
617 },
618 consistency: ConsistencySection {
619 mode: ConsistencyMode::CrashConsistent,
620 backup_units: vec![BackupUnit {
621 unit_id: "whole-fleet".to_string(),
622 kind: BackupUnitKind::WholeFleet,
623 roles: vec!["root".to_string(), "app".to_string()],
624 consistency_reason: None,
625 dependency_closure: Vec::new(),
626 topology_validation: "subtree-closed".to_string(),
627 quiescence_strategy: None,
628 }],
629 },
630 fleet: FleetSection {
631 topology_hash_algorithm: "sha256".to_string(),
632 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
633 discovery_topology_hash: HASH.to_string(),
634 pre_snapshot_topology_hash: HASH.to_string(),
635 topology_hash: HASH.to_string(),
636 members: vec![
637 fleet_member("app", CHILD, Some(ROOT), identity_mode, 1),
638 fleet_member("root", ROOT, None, IdentityMode::Fixed, 1),
639 ],
640 },
641 verification: VerificationPlan {
642 fleet_checks: Vec::new(),
643 member_checks: Vec::new(),
644 },
645 }
646 }
647
648 fn fleet_member(
650 role: &str,
651 canister_id: &str,
652 parent_canister_id: Option<&str>,
653 identity_mode: IdentityMode,
654 restore_group: u16,
655 ) -> FleetMember {
656 FleetMember {
657 role: role.to_string(),
658 canister_id: canister_id.to_string(),
659 parent_canister_id: parent_canister_id.map(str::to_string),
660 subnet_canister_id: None,
661 controller_hint: Some(ROOT.to_string()),
662 identity_mode,
663 restore_group,
664 verification_class: "basic".to_string(),
665 verification_checks: vec![VerificationCheck {
666 kind: "call".to_string(),
667 method: Some("canic_ready".to_string()),
668 roles: Vec::new(),
669 }],
670 source_snapshot: SourceSnapshot {
671 snapshot_id: format!("snap-{role}"),
672 module_hash: Some(HASH.to_string()),
673 wasm_hash: Some(HASH.to_string()),
674 code_version: Some("v0.30.0".to_string()),
675 artifact_path: format!("artifacts/{role}"),
676 checksum_algorithm: "sha256".to_string(),
677 checksum: Some(HASH.to_string()),
678 },
679 }
680 }
681
682 #[test]
684 fn in_place_plan_orders_parent_before_child() {
685 let manifest = valid_manifest(IdentityMode::Relocatable);
686
687 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
688 let ordered = plan.ordered_members();
689
690 assert_eq!(plan.backup_id, "fbk_test_001");
691 assert_eq!(plan.source_environment, "local");
692 assert_eq!(plan.source_root_canister, ROOT);
693 assert_eq!(plan.topology_hash, HASH);
694 assert_eq!(plan.member_count, 2);
695 assert_eq!(plan.identity_summary.fixed_members, 1);
696 assert_eq!(plan.identity_summary.relocatable_members, 1);
697 assert_eq!(plan.identity_summary.in_place_members, 2);
698 assert_eq!(plan.identity_summary.mapped_members, 0);
699 assert_eq!(plan.identity_summary.remapped_members, 0);
700 assert_eq!(plan.verification_summary.fleet_checks, 0);
701 assert_eq!(plan.verification_summary.member_check_groups, 0);
702 assert_eq!(plan.verification_summary.member_checks, 2);
703 assert_eq!(plan.verification_summary.members_with_checks, 2);
704 assert_eq!(plan.verification_summary.total_checks, 2);
705 assert_eq!(plan.ordering_summary.phase_count, 1);
706 assert_eq!(plan.ordering_summary.dependency_free_members, 1);
707 assert_eq!(plan.ordering_summary.in_group_parent_edges, 1);
708 assert_eq!(plan.ordering_summary.cross_group_parent_edges, 0);
709 assert_eq!(ordered[0].phase_order, 0);
710 assert_eq!(ordered[1].phase_order, 1);
711 assert_eq!(ordered[0].source_canister, ROOT);
712 assert_eq!(ordered[1].source_canister, CHILD);
713 assert_eq!(
714 ordered[1].ordering_dependency,
715 Some(RestoreOrderingDependency {
716 source_canister: ROOT.to_string(),
717 target_canister: ROOT.to_string(),
718 relationship: RestoreOrderingRelationship::ParentInSameGroup,
719 })
720 );
721 }
722
723 #[test]
725 fn plan_reports_parent_dependency_from_earlier_group() {
726 let mut manifest = valid_manifest(IdentityMode::Relocatable);
727 manifest.fleet.members[0].restore_group = 2;
728 manifest.fleet.members[1].restore_group = 1;
729
730 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
731 let ordered = plan.ordered_members();
732
733 assert_eq!(plan.phases.len(), 2);
734 assert_eq!(plan.ordering_summary.phase_count, 2);
735 assert_eq!(plan.ordering_summary.dependency_free_members, 1);
736 assert_eq!(plan.ordering_summary.in_group_parent_edges, 0);
737 assert_eq!(plan.ordering_summary.cross_group_parent_edges, 1);
738 assert_eq!(ordered[0].source_canister, ROOT);
739 assert_eq!(ordered[1].source_canister, CHILD);
740 assert_eq!(
741 ordered[1].ordering_dependency,
742 Some(RestoreOrderingDependency {
743 source_canister: ROOT.to_string(),
744 target_canister: ROOT.to_string(),
745 relationship: RestoreOrderingRelationship::ParentInEarlierGroup,
746 })
747 );
748 }
749
750 #[test]
752 fn plan_rejects_parent_in_later_restore_group() {
753 let mut manifest = valid_manifest(IdentityMode::Relocatable);
754 manifest.fleet.members[0].restore_group = 1;
755 manifest.fleet.members[1].restore_group = 2;
756
757 let err = RestorePlanner::plan(&manifest, None)
758 .expect_err("parent-after-child group ordering should fail");
759
760 assert!(matches!(
761 err,
762 RestorePlanError::ParentRestoreGroupAfterChild { .. }
763 ));
764 }
765
766 #[test]
768 fn fixed_identity_member_cannot_be_remapped() {
769 let manifest = valid_manifest(IdentityMode::Fixed);
770 let mapping = RestoreMapping {
771 members: vec![
772 RestoreMappingEntry {
773 source_canister: ROOT.to_string(),
774 target_canister: ROOT.to_string(),
775 },
776 RestoreMappingEntry {
777 source_canister: CHILD.to_string(),
778 target_canister: TARGET.to_string(),
779 },
780 ],
781 };
782
783 let err = RestorePlanner::plan(&manifest, Some(&mapping))
784 .expect_err("fixed member remap should fail");
785
786 assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
787 }
788
789 #[test]
791 fn relocatable_member_can_be_mapped() {
792 let manifest = valid_manifest(IdentityMode::Relocatable);
793 let mapping = RestoreMapping {
794 members: vec![
795 RestoreMappingEntry {
796 source_canister: ROOT.to_string(),
797 target_canister: ROOT.to_string(),
798 },
799 RestoreMappingEntry {
800 source_canister: CHILD.to_string(),
801 target_canister: TARGET.to_string(),
802 },
803 ],
804 };
805
806 let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
807 let child = plan
808 .ordered_members()
809 .into_iter()
810 .find(|member| member.source_canister == CHILD)
811 .expect("child member should be planned");
812
813 assert_eq!(plan.identity_summary.fixed_members, 1);
814 assert_eq!(plan.identity_summary.relocatable_members, 1);
815 assert_eq!(plan.identity_summary.in_place_members, 1);
816 assert_eq!(plan.identity_summary.mapped_members, 2);
817 assert_eq!(plan.identity_summary.remapped_members, 1);
818 assert_eq!(child.target_canister, TARGET);
819 assert_eq!(child.parent_target_canister, Some(ROOT.to_string()));
820 }
821
822 #[test]
824 fn plan_members_include_snapshot_and_verification_metadata() {
825 let manifest = valid_manifest(IdentityMode::Relocatable);
826
827 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
828 let root = plan
829 .ordered_members()
830 .into_iter()
831 .find(|member| member.source_canister == ROOT)
832 .expect("root member should be planned");
833
834 assert_eq!(root.identity_mode, IdentityMode::Fixed);
835 assert_eq!(root.verification_class, "basic");
836 assert_eq!(root.verification_checks[0].kind, "call");
837 assert_eq!(root.source_snapshot.snapshot_id, "snap-root");
838 assert_eq!(root.source_snapshot.artifact_path, "artifacts/root");
839 }
840
841 #[test]
843 fn plan_includes_verification_summary() {
844 let mut manifest = valid_manifest(IdentityMode::Relocatable);
845 manifest.verification.fleet_checks.push(VerificationCheck {
846 kind: "fleet-ready".to_string(),
847 method: None,
848 roles: Vec::new(),
849 });
850 manifest
851 .verification
852 .member_checks
853 .push(MemberVerificationChecks {
854 role: "app".to_string(),
855 checks: vec![VerificationCheck {
856 kind: "app-ready".to_string(),
857 method: Some("ready".to_string()),
858 roles: Vec::new(),
859 }],
860 });
861
862 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
863
864 assert_eq!(plan.verification_summary.fleet_checks, 1);
865 assert_eq!(plan.verification_summary.member_check_groups, 1);
866 assert_eq!(plan.verification_summary.member_checks, 3);
867 assert_eq!(plan.verification_summary.members_with_checks, 2);
868 assert_eq!(plan.verification_summary.total_checks, 4);
869 }
870
871 #[test]
873 fn plan_expands_role_verification_checks_per_matching_member() {
874 let mut manifest = valid_manifest(IdentityMode::Relocatable);
875 manifest.fleet.members.push(fleet_member(
876 "app",
877 CHILD_TWO,
878 Some(ROOT),
879 IdentityMode::Relocatable,
880 1,
881 ));
882 manifest
883 .verification
884 .member_checks
885 .push(MemberVerificationChecks {
886 role: "app".to_string(),
887 checks: vec![VerificationCheck {
888 kind: "app-ready".to_string(),
889 method: Some("ready".to_string()),
890 roles: Vec::new(),
891 }],
892 });
893
894 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
895
896 assert_eq!(plan.verification_summary.fleet_checks, 0);
897 assert_eq!(plan.verification_summary.member_check_groups, 1);
898 assert_eq!(plan.verification_summary.member_checks, 5);
899 assert_eq!(plan.verification_summary.members_with_checks, 3);
900 assert_eq!(plan.verification_summary.total_checks, 5);
901 }
902
903 #[test]
905 fn mapped_restore_requires_complete_mapping() {
906 let manifest = valid_manifest(IdentityMode::Relocatable);
907 let mapping = RestoreMapping {
908 members: vec![RestoreMappingEntry {
909 source_canister: ROOT.to_string(),
910 target_canister: ROOT.to_string(),
911 }],
912 };
913
914 let err = RestorePlanner::plan(&manifest, Some(&mapping))
915 .expect_err("incomplete mapping should fail");
916
917 assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
918 }
919
920 #[test]
922 fn mapped_restore_rejects_unknown_mapping_sources() {
923 let manifest = valid_manifest(IdentityMode::Relocatable);
924 let unknown = "rdmx6-jaaaa-aaaaa-aaadq-cai";
925 let mapping = RestoreMapping {
926 members: vec![
927 RestoreMappingEntry {
928 source_canister: ROOT.to_string(),
929 target_canister: ROOT.to_string(),
930 },
931 RestoreMappingEntry {
932 source_canister: CHILD.to_string(),
933 target_canister: TARGET.to_string(),
934 },
935 RestoreMappingEntry {
936 source_canister: unknown.to_string(),
937 target_canister: unknown.to_string(),
938 },
939 ],
940 };
941
942 let err = RestorePlanner::plan(&manifest, Some(&mapping))
943 .expect_err("unknown mapping source should fail");
944
945 assert!(matches!(err, RestorePlanError::UnknownMappingSource(_)));
946 }
947
948 #[test]
950 fn duplicate_mapping_targets_fail_validation() {
951 let manifest = valid_manifest(IdentityMode::Relocatable);
952 let mapping = RestoreMapping {
953 members: vec![
954 RestoreMappingEntry {
955 source_canister: ROOT.to_string(),
956 target_canister: ROOT.to_string(),
957 },
958 RestoreMappingEntry {
959 source_canister: CHILD.to_string(),
960 target_canister: ROOT.to_string(),
961 },
962 ],
963 };
964
965 let err = RestorePlanner::plan(&manifest, Some(&mapping))
966 .expect_err("duplicate targets should fail");
967
968 assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
969 }
970}