Skip to main content

canic_backup/restore/
mod.rs

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///
14/// RestoreMapping
15///
16
17#[derive(Clone, Debug, Default, Deserialize, Serialize)]
18pub struct RestoreMapping {
19    pub members: Vec<RestoreMappingEntry>,
20}
21
22impl RestoreMapping {
23    /// Resolve the target canister for one source member.
24    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///
33/// RestoreMappingEntry
34///
35
36#[derive(Clone, Debug, Deserialize, Serialize)]
37pub struct RestoreMappingEntry {
38    pub source_canister: String,
39    pub target_canister: String,
40}
41
42///
43/// RestorePlan
44///
45
46#[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    /// Return all planned members in execution order.
64    #[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///
74/// RestoreStatus
75///
76
77#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
78pub struct RestoreStatus {
79    pub status_version: u16,
80    pub backup_id: String,
81    pub source_environment: String,
82    pub source_root_canister: String,
83    pub topology_hash: String,
84    pub ready: bool,
85    pub readiness_reasons: Vec<String>,
86    pub verification_required: bool,
87    pub member_count: usize,
88    pub phase_count: usize,
89    pub planned_snapshot_loads: usize,
90    pub planned_code_reinstalls: usize,
91    pub planned_verification_checks: usize,
92    pub phases: Vec<RestoreStatusPhase>,
93}
94
95impl RestoreStatus {
96    /// Build the initial no-mutation restore status from a computed plan.
97    #[must_use]
98    pub fn from_plan(plan: &RestorePlan) -> Self {
99        Self {
100            status_version: 1,
101            backup_id: plan.backup_id.clone(),
102            source_environment: plan.source_environment.clone(),
103            source_root_canister: plan.source_root_canister.clone(),
104            topology_hash: plan.topology_hash.clone(),
105            ready: plan.readiness_summary.ready,
106            readiness_reasons: plan.readiness_summary.reasons.clone(),
107            verification_required: plan.verification_summary.verification_required,
108            member_count: plan.member_count,
109            phase_count: plan.ordering_summary.phase_count,
110            planned_snapshot_loads: plan.operation_summary.planned_snapshot_loads,
111            planned_code_reinstalls: plan.operation_summary.planned_code_reinstalls,
112            planned_verification_checks: plan.operation_summary.planned_verification_checks,
113            phases: plan
114                .phases
115                .iter()
116                .map(RestoreStatusPhase::from_plan_phase)
117                .collect(),
118        }
119    }
120}
121
122///
123/// RestoreStatusPhase
124///
125
126#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
127pub struct RestoreStatusPhase {
128    pub restore_group: u16,
129    pub members: Vec<RestoreStatusMember>,
130}
131
132impl RestoreStatusPhase {
133    // Build one status phase from one planned restore phase.
134    fn from_plan_phase(phase: &RestorePhase) -> Self {
135        Self {
136            restore_group: phase.restore_group,
137            members: phase
138                .members
139                .iter()
140                .map(RestoreStatusMember::from_plan_member)
141                .collect(),
142        }
143    }
144}
145
146///
147/// RestoreStatusMember
148///
149
150#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
151pub struct RestoreStatusMember {
152    pub source_canister: String,
153    pub target_canister: String,
154    pub role: String,
155    pub restore_group: u16,
156    pub phase_order: usize,
157    pub snapshot_id: String,
158    pub artifact_path: String,
159    pub state: RestoreMemberState,
160}
161
162impl RestoreStatusMember {
163    // Build one member status row from one planned restore member.
164    fn from_plan_member(member: &RestorePlanMember) -> Self {
165        Self {
166            source_canister: member.source_canister.clone(),
167            target_canister: member.target_canister.clone(),
168            role: member.role.clone(),
169            restore_group: member.restore_group,
170            phase_order: member.phase_order,
171            snapshot_id: member.source_snapshot.snapshot_id.clone(),
172            artifact_path: member.source_snapshot.artifact_path.clone(),
173            state: RestoreMemberState::Planned,
174        }
175    }
176}
177
178///
179/// RestoreMemberState
180///
181
182#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
183#[serde(rename_all = "kebab-case")]
184pub enum RestoreMemberState {
185    Planned,
186}
187
188///
189/// RestoreIdentitySummary
190///
191
192#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
193pub struct RestoreIdentitySummary {
194    pub mapping_supplied: bool,
195    pub all_sources_mapped: bool,
196    pub fixed_members: usize,
197    pub relocatable_members: usize,
198    pub in_place_members: usize,
199    pub mapped_members: usize,
200    pub remapped_members: usize,
201}
202
203///
204/// RestoreSnapshotSummary
205///
206
207#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
208#[expect(
209    clippy::struct_excessive_bools,
210    reason = "restore summaries intentionally expose machine-readable readiness flags"
211)]
212pub struct RestoreSnapshotSummary {
213    pub all_members_have_module_hash: bool,
214    pub all_members_have_wasm_hash: bool,
215    pub all_members_have_code_version: bool,
216    pub all_members_have_checksum: bool,
217    pub members_with_module_hash: usize,
218    pub members_with_wasm_hash: usize,
219    pub members_with_code_version: usize,
220    pub members_with_checksum: usize,
221}
222
223///
224/// RestoreVerificationSummary
225///
226
227#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
228pub struct RestoreVerificationSummary {
229    pub verification_required: bool,
230    pub all_members_have_checks: bool,
231    pub fleet_checks: usize,
232    pub member_check_groups: usize,
233    pub member_checks: usize,
234    pub members_with_checks: usize,
235    pub total_checks: usize,
236}
237
238///
239/// RestoreReadinessSummary
240///
241
242#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
243pub struct RestoreReadinessSummary {
244    pub ready: bool,
245    pub reasons: Vec<String>,
246}
247
248///
249/// RestoreOperationSummary
250///
251
252#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
253pub struct RestoreOperationSummary {
254    pub planned_snapshot_loads: usize,
255    pub planned_code_reinstalls: usize,
256    pub planned_verification_checks: usize,
257    pub planned_phases: usize,
258}
259
260///
261/// RestoreOrderingSummary
262///
263
264#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
265pub struct RestoreOrderingSummary {
266    pub phase_count: usize,
267    pub dependency_free_members: usize,
268    pub in_group_parent_edges: usize,
269    pub cross_group_parent_edges: usize,
270}
271
272///
273/// RestorePhase
274///
275
276#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
277pub struct RestorePhase {
278    pub restore_group: u16,
279    pub members: Vec<RestorePlanMember>,
280}
281
282///
283/// RestorePlanMember
284///
285
286#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
287pub struct RestorePlanMember {
288    pub source_canister: String,
289    pub target_canister: String,
290    pub role: String,
291    pub parent_source_canister: Option<String>,
292    pub parent_target_canister: Option<String>,
293    pub ordering_dependency: Option<RestoreOrderingDependency>,
294    pub phase_order: usize,
295    pub restore_group: u16,
296    pub identity_mode: IdentityMode,
297    pub verification_class: String,
298    pub verification_checks: Vec<VerificationCheck>,
299    pub source_snapshot: SourceSnapshot,
300}
301
302///
303/// RestoreOrderingDependency
304///
305
306#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
307pub struct RestoreOrderingDependency {
308    pub source_canister: String,
309    pub target_canister: String,
310    pub relationship: RestoreOrderingRelationship,
311}
312
313///
314/// RestoreOrderingRelationship
315///
316
317#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
318#[serde(rename_all = "kebab-case")]
319pub enum RestoreOrderingRelationship {
320    ParentInSameGroup,
321    ParentInEarlierGroup,
322}
323
324///
325/// RestorePlanner
326///
327
328pub struct RestorePlanner;
329
330impl RestorePlanner {
331    /// Build a no-mutation restore plan from the manifest and optional target mapping.
332    pub fn plan(
333        manifest: &FleetBackupManifest,
334        mapping: Option<&RestoreMapping>,
335    ) -> Result<RestorePlan, RestorePlanError> {
336        manifest.validate()?;
337        if let Some(mapping) = mapping {
338            validate_mapping(mapping)?;
339            validate_mapping_sources(manifest, mapping)?;
340        }
341
342        let members = resolve_members(manifest, mapping)?;
343        let identity_summary = restore_identity_summary(&members, mapping.is_some());
344        let snapshot_summary = restore_snapshot_summary(&members);
345        let verification_summary = restore_verification_summary(manifest, &members);
346        let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
347        validate_restore_group_dependencies(&members)?;
348        let phases = group_and_order_members(members)?;
349        let ordering_summary = restore_ordering_summary(&phases);
350        let operation_summary =
351            restore_operation_summary(manifest.fleet.members.len(), &verification_summary, &phases);
352
353        Ok(RestorePlan {
354            backup_id: manifest.backup_id.clone(),
355            source_environment: manifest.source.environment.clone(),
356            source_root_canister: manifest.source.root_canister.clone(),
357            topology_hash: manifest.fleet.topology_hash.clone(),
358            member_count: manifest.fleet.members.len(),
359            identity_summary,
360            snapshot_summary,
361            verification_summary,
362            readiness_summary,
363            operation_summary,
364            ordering_summary,
365            phases,
366        })
367    }
368}
369
370///
371/// RestorePlanError
372///
373
374#[derive(Debug, ThisError)]
375pub enum RestorePlanError {
376    #[error(transparent)]
377    InvalidManifest(#[from] ManifestValidationError),
378
379    #[error("field {field} must be a valid principal: {value}")]
380    InvalidPrincipal { field: &'static str, value: String },
381
382    #[error("mapping contains duplicate source canister {0}")]
383    DuplicateMappingSource(String),
384
385    #[error("mapping contains duplicate target canister {0}")]
386    DuplicateMappingTarget(String),
387
388    #[error("mapping references unknown source canister {0}")]
389    UnknownMappingSource(String),
390
391    #[error("mapping is missing source canister {0}")]
392    MissingMappingSource(String),
393
394    #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
395    FixedIdentityRemap {
396        source_canister: String,
397        target_canister: String,
398    },
399
400    #[error("restore plan contains duplicate target canister {0}")]
401    DuplicatePlanTarget(String),
402
403    #[error("restore group {0} contains a parent cycle or unresolved dependency")]
404    RestoreOrderCycle(u16),
405
406    #[error(
407        "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
408    )]
409    ParentRestoreGroupAfterChild {
410        child_source_canister: String,
411        parent_source_canister: String,
412        child_restore_group: u16,
413        parent_restore_group: u16,
414    },
415}
416
417// Validate a user-supplied restore mapping before applying it to the manifest.
418fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
419    let mut sources = BTreeSet::new();
420    let mut targets = BTreeSet::new();
421
422    for entry in &mapping.members {
423        validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
424        validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
425
426        if !sources.insert(entry.source_canister.clone()) {
427            return Err(RestorePlanError::DuplicateMappingSource(
428                entry.source_canister.clone(),
429            ));
430        }
431
432        if !targets.insert(entry.target_canister.clone()) {
433            return Err(RestorePlanError::DuplicateMappingTarget(
434                entry.target_canister.clone(),
435            ));
436        }
437    }
438
439    Ok(())
440}
441
442// Ensure mappings only reference members declared in the manifest.
443fn validate_mapping_sources(
444    manifest: &FleetBackupManifest,
445    mapping: &RestoreMapping,
446) -> Result<(), RestorePlanError> {
447    let sources = manifest
448        .fleet
449        .members
450        .iter()
451        .map(|member| member.canister_id.as_str())
452        .collect::<BTreeSet<_>>();
453
454    for entry in &mapping.members {
455        if !sources.contains(entry.source_canister.as_str()) {
456            return Err(RestorePlanError::UnknownMappingSource(
457                entry.source_canister.clone(),
458            ));
459        }
460    }
461
462    Ok(())
463}
464
465// Resolve source manifest members into target restore members.
466fn resolve_members(
467    manifest: &FleetBackupManifest,
468    mapping: Option<&RestoreMapping>,
469) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
470    let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
471    let mut targets = BTreeSet::new();
472    let mut source_to_target = BTreeMap::new();
473
474    for member in &manifest.fleet.members {
475        let target = resolve_target(member, mapping)?;
476        if !targets.insert(target.clone()) {
477            return Err(RestorePlanError::DuplicatePlanTarget(target));
478        }
479
480        source_to_target.insert(member.canister_id.clone(), target.clone());
481        plan_members.push(RestorePlanMember {
482            source_canister: member.canister_id.clone(),
483            target_canister: target,
484            role: member.role.clone(),
485            parent_source_canister: member.parent_canister_id.clone(),
486            parent_target_canister: None,
487            ordering_dependency: None,
488            phase_order: 0,
489            restore_group: member.restore_group,
490            identity_mode: member.identity_mode.clone(),
491            verification_class: member.verification_class.clone(),
492            verification_checks: member.verification_checks.clone(),
493            source_snapshot: member.source_snapshot.clone(),
494        });
495    }
496
497    for member in &mut plan_members {
498        member.parent_target_canister = member
499            .parent_source_canister
500            .as_ref()
501            .and_then(|parent| source_to_target.get(parent))
502            .cloned();
503    }
504
505    Ok(plan_members)
506}
507
508// Resolve one member's target canister, enforcing identity continuity.
509fn resolve_target(
510    member: &FleetMember,
511    mapping: Option<&RestoreMapping>,
512) -> Result<String, RestorePlanError> {
513    let target = match mapping {
514        Some(mapping) => mapping
515            .target_for(&member.canister_id)
516            .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
517            .to_string(),
518        None => member.canister_id.clone(),
519    };
520
521    if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
522        return Err(RestorePlanError::FixedIdentityRemap {
523            source_canister: member.canister_id.clone(),
524            target_canister: target,
525        });
526    }
527
528    Ok(target)
529}
530
531// Summarize identity and mapping decisions before grouping restore phases.
532fn restore_identity_summary(
533    members: &[RestorePlanMember],
534    mapping_supplied: bool,
535) -> RestoreIdentitySummary {
536    let mut summary = RestoreIdentitySummary {
537        mapping_supplied,
538        all_sources_mapped: false,
539        fixed_members: 0,
540        relocatable_members: 0,
541        in_place_members: 0,
542        mapped_members: 0,
543        remapped_members: 0,
544    };
545
546    for member in members {
547        match member.identity_mode {
548            IdentityMode::Fixed => summary.fixed_members += 1,
549            IdentityMode::Relocatable => summary.relocatable_members += 1,
550        }
551
552        if member.source_canister == member.target_canister {
553            summary.in_place_members += 1;
554        } else {
555            summary.remapped_members += 1;
556        }
557        if mapping_supplied {
558            summary.mapped_members += 1;
559        }
560    }
561
562    summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
563
564    summary
565}
566
567// Summarize snapshot provenance completeness before grouping restore phases.
568fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
569    let members_with_module_hash = members
570        .iter()
571        .filter(|member| member.source_snapshot.module_hash.is_some())
572        .count();
573    let members_with_wasm_hash = members
574        .iter()
575        .filter(|member| member.source_snapshot.wasm_hash.is_some())
576        .count();
577    let members_with_code_version = members
578        .iter()
579        .filter(|member| member.source_snapshot.code_version.is_some())
580        .count();
581    let members_with_checksum = members
582        .iter()
583        .filter(|member| member.source_snapshot.checksum.is_some())
584        .count();
585
586    RestoreSnapshotSummary {
587        all_members_have_module_hash: members_with_module_hash == members.len(),
588        all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
589        all_members_have_code_version: members_with_code_version == members.len(),
590        all_members_have_checksum: members_with_checksum == members.len(),
591        members_with_module_hash,
592        members_with_wasm_hash,
593        members_with_code_version,
594        members_with_checksum,
595    }
596}
597
598// Summarize whether restore planning has the metadata required for automation.
599fn restore_readiness_summary(
600    snapshot: &RestoreSnapshotSummary,
601    verification: &RestoreVerificationSummary,
602) -> RestoreReadinessSummary {
603    let mut reasons = Vec::new();
604
605    if !snapshot.all_members_have_module_hash {
606        reasons.push("missing-module-hash".to_string());
607    }
608    if !snapshot.all_members_have_wasm_hash {
609        reasons.push("missing-wasm-hash".to_string());
610    }
611    if !snapshot.all_members_have_code_version {
612        reasons.push("missing-code-version".to_string());
613    }
614    if !snapshot.all_members_have_checksum {
615        reasons.push("missing-snapshot-checksum".to_string());
616    }
617    if !verification.all_members_have_checks {
618        reasons.push("missing-verification-checks".to_string());
619    }
620
621    RestoreReadinessSummary {
622        ready: reasons.is_empty(),
623        reasons,
624    }
625}
626
627// Summarize restore verification work declared by the manifest and members.
628fn restore_verification_summary(
629    manifest: &FleetBackupManifest,
630    members: &[RestorePlanMember],
631) -> RestoreVerificationSummary {
632    let fleet_checks = manifest.verification.fleet_checks.len();
633    let member_check_groups = manifest.verification.member_checks.len();
634    let role_check_counts = manifest
635        .verification
636        .member_checks
637        .iter()
638        .map(|group| (group.role.as_str(), group.checks.len()))
639        .collect::<BTreeMap<_, _>>();
640    let inline_member_checks = members
641        .iter()
642        .map(|member| member.verification_checks.len())
643        .sum::<usize>();
644    let role_member_checks = members
645        .iter()
646        .map(|member| {
647            role_check_counts
648                .get(member.role.as_str())
649                .copied()
650                .unwrap_or(0)
651        })
652        .sum::<usize>();
653    let member_checks = inline_member_checks + role_member_checks;
654    let members_with_checks = members
655        .iter()
656        .filter(|member| {
657            !member.verification_checks.is_empty()
658                || role_check_counts.contains_key(member.role.as_str())
659        })
660        .count();
661
662    RestoreVerificationSummary {
663        verification_required: true,
664        all_members_have_checks: members_with_checks == members.len(),
665        fleet_checks,
666        member_check_groups,
667        member_checks,
668        members_with_checks,
669        total_checks: fleet_checks + member_checks,
670    }
671}
672
673// Summarize the concrete restore operations implied by a no-mutation plan.
674const fn restore_operation_summary(
675    member_count: usize,
676    verification_summary: &RestoreVerificationSummary,
677    phases: &[RestorePhase],
678) -> RestoreOperationSummary {
679    RestoreOperationSummary {
680        planned_snapshot_loads: member_count,
681        planned_code_reinstalls: member_count,
682        planned_verification_checks: verification_summary.total_checks,
683        planned_phases: phases.len(),
684    }
685}
686
687// Reject group assignments that would restore a child before its parent.
688fn validate_restore_group_dependencies(
689    members: &[RestorePlanMember],
690) -> Result<(), RestorePlanError> {
691    let groups_by_source = members
692        .iter()
693        .map(|member| (member.source_canister.as_str(), member.restore_group))
694        .collect::<BTreeMap<_, _>>();
695
696    for member in members {
697        let Some(parent) = &member.parent_source_canister else {
698            continue;
699        };
700        let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
701            continue;
702        };
703
704        if *parent_group > member.restore_group {
705            return Err(RestorePlanError::ParentRestoreGroupAfterChild {
706                child_source_canister: member.source_canister.clone(),
707                parent_source_canister: parent.clone(),
708                child_restore_group: member.restore_group,
709                parent_restore_group: *parent_group,
710            });
711        }
712    }
713
714    Ok(())
715}
716
717// Group members and apply parent-before-child ordering inside each group.
718fn group_and_order_members(
719    members: Vec<RestorePlanMember>,
720) -> Result<Vec<RestorePhase>, RestorePlanError> {
721    let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
722    for member in members {
723        groups.entry(member.restore_group).or_default().push(member);
724    }
725
726    groups
727        .into_iter()
728        .map(|(restore_group, members)| {
729            let members = order_group(restore_group, members)?;
730            Ok(RestorePhase {
731                restore_group,
732                members,
733            })
734        })
735        .collect()
736}
737
738// Topologically order one group using manifest parent relationships.
739fn order_group(
740    restore_group: u16,
741    members: Vec<RestorePlanMember>,
742) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
743    let mut remaining = members;
744    let group_sources = remaining
745        .iter()
746        .map(|member| member.source_canister.clone())
747        .collect::<BTreeSet<_>>();
748    let mut emitted = BTreeSet::new();
749    let mut ordered = Vec::with_capacity(remaining.len());
750
751    while !remaining.is_empty() {
752        let Some(index) = remaining
753            .iter()
754            .position(|member| parent_satisfied(member, &group_sources, &emitted))
755        else {
756            return Err(RestorePlanError::RestoreOrderCycle(restore_group));
757        };
758
759        let mut member = remaining.remove(index);
760        member.phase_order = ordered.len();
761        member.ordering_dependency = ordering_dependency(&member, &group_sources);
762        emitted.insert(member.source_canister.clone());
763        ordered.push(member);
764    }
765
766    Ok(ordered)
767}
768
769// Describe the topology dependency that controlled a member's restore ordering.
770fn ordering_dependency(
771    member: &RestorePlanMember,
772    group_sources: &BTreeSet<String>,
773) -> Option<RestoreOrderingDependency> {
774    let parent_source = member.parent_source_canister.as_ref()?;
775    let parent_target = member.parent_target_canister.as_ref()?;
776    let relationship = if group_sources.contains(parent_source) {
777        RestoreOrderingRelationship::ParentInSameGroup
778    } else {
779        RestoreOrderingRelationship::ParentInEarlierGroup
780    };
781
782    Some(RestoreOrderingDependency {
783        source_canister: parent_source.clone(),
784        target_canister: parent_target.clone(),
785        relationship,
786    })
787}
788
789// Summarize the dependency ordering metadata exposed in the restore plan.
790fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
791    let mut summary = RestoreOrderingSummary {
792        phase_count: phases.len(),
793        dependency_free_members: 0,
794        in_group_parent_edges: 0,
795        cross_group_parent_edges: 0,
796    };
797
798    for member in phases.iter().flat_map(|phase| phase.members.iter()) {
799        match &member.ordering_dependency {
800            Some(dependency)
801                if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
802            {
803                summary.in_group_parent_edges += 1;
804            }
805            Some(dependency)
806                if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
807            {
808                summary.cross_group_parent_edges += 1;
809            }
810            Some(_) => {}
811            None => summary.dependency_free_members += 1,
812        }
813    }
814
815    summary
816}
817
818// Determine whether a member's in-group parent has already been emitted.
819fn parent_satisfied(
820    member: &RestorePlanMember,
821    group_sources: &BTreeSet<String>,
822    emitted: &BTreeSet<String>,
823) -> bool {
824    match &member.parent_source_canister {
825        Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
826        _ => true,
827    }
828}
829
830// Validate textual principal fields used in mappings.
831fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
832    Principal::from_str(value)
833        .map(|_| ())
834        .map_err(|_| RestorePlanError::InvalidPrincipal {
835            field,
836            value: value.to_string(),
837        })
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843    use crate::manifest::{
844        BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetSection,
845        MemberVerificationChecks, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
846        VerificationPlan,
847    };
848
849    const ROOT: &str = "aaaaa-aa";
850    const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
851    const CHILD_TWO: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";
852    const TARGET: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
853    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
854
855    // Build one valid manifest with a parent and child in the same restore group.
856    fn valid_manifest(identity_mode: IdentityMode) -> FleetBackupManifest {
857        FleetBackupManifest {
858            manifest_version: 1,
859            backup_id: "fbk_test_001".to_string(),
860            created_at: "2026-04-10T12:00:00Z".to_string(),
861            tool: ToolMetadata {
862                name: "canic".to_string(),
863                version: "v1".to_string(),
864            },
865            source: SourceMetadata {
866                environment: "local".to_string(),
867                root_canister: ROOT.to_string(),
868            },
869            consistency: ConsistencySection {
870                mode: ConsistencyMode::CrashConsistent,
871                backup_units: vec![BackupUnit {
872                    unit_id: "whole-fleet".to_string(),
873                    kind: BackupUnitKind::WholeFleet,
874                    roles: vec!["root".to_string(), "app".to_string()],
875                    consistency_reason: None,
876                    dependency_closure: Vec::new(),
877                    topology_validation: "subtree-closed".to_string(),
878                    quiescence_strategy: None,
879                }],
880            },
881            fleet: FleetSection {
882                topology_hash_algorithm: "sha256".to_string(),
883                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
884                discovery_topology_hash: HASH.to_string(),
885                pre_snapshot_topology_hash: HASH.to_string(),
886                topology_hash: HASH.to_string(),
887                members: vec![
888                    fleet_member("app", CHILD, Some(ROOT), identity_mode, 1),
889                    fleet_member("root", ROOT, None, IdentityMode::Fixed, 1),
890                ],
891            },
892            verification: VerificationPlan {
893                fleet_checks: Vec::new(),
894                member_checks: Vec::new(),
895            },
896        }
897    }
898
899    // Build one manifest member for restore planning tests.
900    fn fleet_member(
901        role: &str,
902        canister_id: &str,
903        parent_canister_id: Option<&str>,
904        identity_mode: IdentityMode,
905        restore_group: u16,
906    ) -> FleetMember {
907        FleetMember {
908            role: role.to_string(),
909            canister_id: canister_id.to_string(),
910            parent_canister_id: parent_canister_id.map(str::to_string),
911            subnet_canister_id: None,
912            controller_hint: Some(ROOT.to_string()),
913            identity_mode,
914            restore_group,
915            verification_class: "basic".to_string(),
916            verification_checks: vec![VerificationCheck {
917                kind: "call".to_string(),
918                method: Some("canic_ready".to_string()),
919                roles: Vec::new(),
920            }],
921            source_snapshot: SourceSnapshot {
922                snapshot_id: format!("snap-{role}"),
923                module_hash: Some(HASH.to_string()),
924                wasm_hash: Some(HASH.to_string()),
925                code_version: Some("v0.30.0".to_string()),
926                artifact_path: format!("artifacts/{role}"),
927                checksum_algorithm: "sha256".to_string(),
928                checksum: Some(HASH.to_string()),
929            },
930        }
931    }
932
933    // Ensure in-place restore planning sorts parent before child.
934    #[test]
935    fn in_place_plan_orders_parent_before_child() {
936        let manifest = valid_manifest(IdentityMode::Relocatable);
937
938        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
939        let ordered = plan.ordered_members();
940
941        assert_eq!(plan.backup_id, "fbk_test_001");
942        assert_eq!(plan.source_environment, "local");
943        assert_eq!(plan.source_root_canister, ROOT);
944        assert_eq!(plan.topology_hash, HASH);
945        assert_eq!(plan.member_count, 2);
946        assert_eq!(plan.identity_summary.fixed_members, 1);
947        assert_eq!(plan.identity_summary.relocatable_members, 1);
948        assert_eq!(plan.identity_summary.in_place_members, 2);
949        assert_eq!(plan.identity_summary.mapped_members, 0);
950        assert_eq!(plan.identity_summary.remapped_members, 0);
951        assert!(plan.verification_summary.verification_required);
952        assert!(plan.verification_summary.all_members_have_checks);
953        assert!(plan.readiness_summary.ready);
954        assert!(plan.readiness_summary.reasons.is_empty());
955        assert_eq!(plan.verification_summary.fleet_checks, 0);
956        assert_eq!(plan.verification_summary.member_check_groups, 0);
957        assert_eq!(plan.verification_summary.member_checks, 2);
958        assert_eq!(plan.verification_summary.members_with_checks, 2);
959        assert_eq!(plan.verification_summary.total_checks, 2);
960        assert_eq!(plan.ordering_summary.phase_count, 1);
961        assert_eq!(plan.ordering_summary.dependency_free_members, 1);
962        assert_eq!(plan.ordering_summary.in_group_parent_edges, 1);
963        assert_eq!(plan.ordering_summary.cross_group_parent_edges, 0);
964        assert_eq!(ordered[0].phase_order, 0);
965        assert_eq!(ordered[1].phase_order, 1);
966        assert_eq!(ordered[0].source_canister, ROOT);
967        assert_eq!(ordered[1].source_canister, CHILD);
968        assert_eq!(
969            ordered[1].ordering_dependency,
970            Some(RestoreOrderingDependency {
971                source_canister: ROOT.to_string(),
972                target_canister: ROOT.to_string(),
973                relationship: RestoreOrderingRelationship::ParentInSameGroup,
974            })
975        );
976    }
977
978    // Ensure cross-group parent dependencies are exposed when the parent phase is earlier.
979    #[test]
980    fn plan_reports_parent_dependency_from_earlier_group() {
981        let mut manifest = valid_manifest(IdentityMode::Relocatable);
982        manifest.fleet.members[0].restore_group = 2;
983        manifest.fleet.members[1].restore_group = 1;
984
985        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
986        let ordered = plan.ordered_members();
987
988        assert_eq!(plan.phases.len(), 2);
989        assert_eq!(plan.ordering_summary.phase_count, 2);
990        assert_eq!(plan.ordering_summary.dependency_free_members, 1);
991        assert_eq!(plan.ordering_summary.in_group_parent_edges, 0);
992        assert_eq!(plan.ordering_summary.cross_group_parent_edges, 1);
993        assert_eq!(ordered[0].source_canister, ROOT);
994        assert_eq!(ordered[1].source_canister, CHILD);
995        assert_eq!(
996            ordered[1].ordering_dependency,
997            Some(RestoreOrderingDependency {
998                source_canister: ROOT.to_string(),
999                target_canister: ROOT.to_string(),
1000                relationship: RestoreOrderingRelationship::ParentInEarlierGroup,
1001            })
1002        );
1003    }
1004
1005    // Ensure restore planning fails when groups would restore a child before its parent.
1006    #[test]
1007    fn plan_rejects_parent_in_later_restore_group() {
1008        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1009        manifest.fleet.members[0].restore_group = 1;
1010        manifest.fleet.members[1].restore_group = 2;
1011
1012        let err = RestorePlanner::plan(&manifest, None)
1013            .expect_err("parent-after-child group ordering should fail");
1014
1015        assert!(matches!(
1016            err,
1017            RestorePlanError::ParentRestoreGroupAfterChild { .. }
1018        ));
1019    }
1020
1021    // Ensure fixed identities cannot be remapped.
1022    #[test]
1023    fn fixed_identity_member_cannot_be_remapped() {
1024        let manifest = valid_manifest(IdentityMode::Fixed);
1025        let mapping = RestoreMapping {
1026            members: vec![
1027                RestoreMappingEntry {
1028                    source_canister: ROOT.to_string(),
1029                    target_canister: ROOT.to_string(),
1030                },
1031                RestoreMappingEntry {
1032                    source_canister: CHILD.to_string(),
1033                    target_canister: TARGET.to_string(),
1034                },
1035            ],
1036        };
1037
1038        let err = RestorePlanner::plan(&manifest, Some(&mapping))
1039            .expect_err("fixed member remap should fail");
1040
1041        assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
1042    }
1043
1044    // Ensure relocatable identities may be mapped when all members are covered.
1045    #[test]
1046    fn relocatable_member_can_be_mapped() {
1047        let manifest = valid_manifest(IdentityMode::Relocatable);
1048        let mapping = RestoreMapping {
1049            members: vec![
1050                RestoreMappingEntry {
1051                    source_canister: ROOT.to_string(),
1052                    target_canister: ROOT.to_string(),
1053                },
1054                RestoreMappingEntry {
1055                    source_canister: CHILD.to_string(),
1056                    target_canister: TARGET.to_string(),
1057                },
1058            ],
1059        };
1060
1061        let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
1062        let child = plan
1063            .ordered_members()
1064            .into_iter()
1065            .find(|member| member.source_canister == CHILD)
1066            .expect("child member should be planned");
1067
1068        assert_eq!(plan.identity_summary.fixed_members, 1);
1069        assert_eq!(plan.identity_summary.relocatable_members, 1);
1070        assert_eq!(plan.identity_summary.in_place_members, 1);
1071        assert_eq!(plan.identity_summary.mapped_members, 2);
1072        assert_eq!(plan.identity_summary.remapped_members, 1);
1073        assert_eq!(child.target_canister, TARGET);
1074        assert_eq!(child.parent_target_canister, Some(ROOT.to_string()));
1075    }
1076
1077    // Ensure restore plans carry enough metadata for operator preflight.
1078    #[test]
1079    fn plan_members_include_snapshot_and_verification_metadata() {
1080        let manifest = valid_manifest(IdentityMode::Relocatable);
1081
1082        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1083        let root = plan
1084            .ordered_members()
1085            .into_iter()
1086            .find(|member| member.source_canister == ROOT)
1087            .expect("root member should be planned");
1088
1089        assert_eq!(root.identity_mode, IdentityMode::Fixed);
1090        assert_eq!(root.verification_class, "basic");
1091        assert_eq!(root.verification_checks[0].kind, "call");
1092        assert_eq!(root.source_snapshot.snapshot_id, "snap-root");
1093        assert_eq!(root.source_snapshot.artifact_path, "artifacts/root");
1094    }
1095
1096    // Ensure restore plans make mapping mode explicit.
1097    #[test]
1098    fn plan_includes_mapping_summary() {
1099        let manifest = valid_manifest(IdentityMode::Relocatable);
1100        let in_place = RestorePlanner::plan(&manifest, None).expect("plan should build");
1101
1102        assert!(!in_place.identity_summary.mapping_supplied);
1103        assert!(!in_place.identity_summary.all_sources_mapped);
1104        assert_eq!(in_place.identity_summary.mapped_members, 0);
1105
1106        let mapping = RestoreMapping {
1107            members: vec![
1108                RestoreMappingEntry {
1109                    source_canister: ROOT.to_string(),
1110                    target_canister: ROOT.to_string(),
1111                },
1112                RestoreMappingEntry {
1113                    source_canister: CHILD.to_string(),
1114                    target_canister: TARGET.to_string(),
1115                },
1116            ],
1117        };
1118        let mapped = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
1119
1120        assert!(mapped.identity_summary.mapping_supplied);
1121        assert!(mapped.identity_summary.all_sources_mapped);
1122        assert_eq!(mapped.identity_summary.mapped_members, 2);
1123        assert_eq!(mapped.identity_summary.remapped_members, 1);
1124    }
1125
1126    // Ensure restore plans summarize snapshot provenance completeness.
1127    #[test]
1128    fn plan_includes_snapshot_summary() {
1129        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1130        manifest.fleet.members[1].source_snapshot.module_hash = None;
1131        manifest.fleet.members[1].source_snapshot.wasm_hash = None;
1132        manifest.fleet.members[1].source_snapshot.checksum = None;
1133
1134        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1135
1136        assert!(!plan.snapshot_summary.all_members_have_module_hash);
1137        assert!(!plan.snapshot_summary.all_members_have_wasm_hash);
1138        assert!(plan.snapshot_summary.all_members_have_code_version);
1139        assert!(!plan.snapshot_summary.all_members_have_checksum);
1140        assert_eq!(plan.snapshot_summary.members_with_module_hash, 1);
1141        assert_eq!(plan.snapshot_summary.members_with_wasm_hash, 1);
1142        assert_eq!(plan.snapshot_summary.members_with_code_version, 2);
1143        assert_eq!(plan.snapshot_summary.members_with_checksum, 1);
1144        assert!(!plan.readiness_summary.ready);
1145        assert_eq!(
1146            plan.readiness_summary.reasons,
1147            [
1148                "missing-module-hash",
1149                "missing-wasm-hash",
1150                "missing-snapshot-checksum"
1151            ]
1152        );
1153    }
1154
1155    // Ensure restore plans summarize manifest-level verification work.
1156    #[test]
1157    fn plan_includes_verification_summary() {
1158        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1159        manifest.verification.fleet_checks.push(VerificationCheck {
1160            kind: "fleet-ready".to_string(),
1161            method: None,
1162            roles: Vec::new(),
1163        });
1164        manifest
1165            .verification
1166            .member_checks
1167            .push(MemberVerificationChecks {
1168                role: "app".to_string(),
1169                checks: vec![VerificationCheck {
1170                    kind: "app-ready".to_string(),
1171                    method: Some("ready".to_string()),
1172                    roles: Vec::new(),
1173                }],
1174            });
1175
1176        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1177
1178        assert!(plan.verification_summary.verification_required);
1179        assert!(plan.verification_summary.all_members_have_checks);
1180        assert_eq!(plan.verification_summary.fleet_checks, 1);
1181        assert_eq!(plan.verification_summary.member_check_groups, 1);
1182        assert_eq!(plan.verification_summary.member_checks, 3);
1183        assert_eq!(plan.verification_summary.members_with_checks, 2);
1184        assert_eq!(plan.verification_summary.total_checks, 4);
1185    }
1186
1187    // Ensure restore plans summarize the concrete operation counts automation will schedule.
1188    #[test]
1189    fn plan_includes_operation_summary() {
1190        let manifest = valid_manifest(IdentityMode::Relocatable);
1191
1192        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1193
1194        assert_eq!(plan.operation_summary.planned_snapshot_loads, 2);
1195        assert_eq!(plan.operation_summary.planned_code_reinstalls, 2);
1196        assert_eq!(plan.operation_summary.planned_verification_checks, 2);
1197        assert_eq!(plan.operation_summary.planned_phases, 1);
1198    }
1199
1200    // Ensure initial restore status mirrors the no-mutation restore plan.
1201    #[test]
1202    fn restore_status_starts_all_members_as_planned() {
1203        let manifest = valid_manifest(IdentityMode::Relocatable);
1204
1205        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1206        let status = RestoreStatus::from_plan(&plan);
1207
1208        assert_eq!(status.status_version, 1);
1209        assert_eq!(status.backup_id.as_str(), plan.backup_id.as_str());
1210        assert_eq!(
1211            status.source_environment.as_str(),
1212            plan.source_environment.as_str()
1213        );
1214        assert_eq!(
1215            status.source_root_canister.as_str(),
1216            plan.source_root_canister.as_str()
1217        );
1218        assert_eq!(status.topology_hash.as_str(), plan.topology_hash.as_str());
1219        assert!(status.ready);
1220        assert!(status.readiness_reasons.is_empty());
1221        assert!(status.verification_required);
1222        assert_eq!(status.member_count, 2);
1223        assert_eq!(status.phase_count, 1);
1224        assert_eq!(status.planned_snapshot_loads, 2);
1225        assert_eq!(status.planned_code_reinstalls, 2);
1226        assert_eq!(status.planned_verification_checks, 2);
1227        assert_eq!(status.phases.len(), 1);
1228        assert_eq!(status.phases[0].restore_group, 1);
1229        assert_eq!(status.phases[0].members.len(), 2);
1230        assert_eq!(
1231            status.phases[0].members[0].state,
1232            RestoreMemberState::Planned
1233        );
1234        assert_eq!(status.phases[0].members[0].source_canister, ROOT);
1235        assert_eq!(status.phases[0].members[0].target_canister, ROOT);
1236        assert_eq!(status.phases[0].members[0].snapshot_id, "snap-root");
1237        assert_eq!(status.phases[0].members[0].artifact_path, "artifacts/root");
1238        assert_eq!(
1239            status.phases[0].members[1].state,
1240            RestoreMemberState::Planned
1241        );
1242        assert_eq!(status.phases[0].members[1].source_canister, CHILD);
1243    }
1244
1245    // Ensure role-level verification checks are counted once per matching member.
1246    #[test]
1247    fn plan_expands_role_verification_checks_per_matching_member() {
1248        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1249        manifest.fleet.members.push(fleet_member(
1250            "app",
1251            CHILD_TWO,
1252            Some(ROOT),
1253            IdentityMode::Relocatable,
1254            1,
1255        ));
1256        manifest
1257            .verification
1258            .member_checks
1259            .push(MemberVerificationChecks {
1260                role: "app".to_string(),
1261                checks: vec![VerificationCheck {
1262                    kind: "app-ready".to_string(),
1263                    method: Some("ready".to_string()),
1264                    roles: Vec::new(),
1265                }],
1266            });
1267
1268        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1269
1270        assert_eq!(plan.verification_summary.fleet_checks, 0);
1271        assert_eq!(plan.verification_summary.member_check_groups, 1);
1272        assert_eq!(plan.verification_summary.member_checks, 5);
1273        assert_eq!(plan.verification_summary.members_with_checks, 3);
1274        assert_eq!(plan.verification_summary.total_checks, 5);
1275    }
1276
1277    // Ensure mapped restores must cover every source member.
1278    #[test]
1279    fn mapped_restore_requires_complete_mapping() {
1280        let manifest = valid_manifest(IdentityMode::Relocatable);
1281        let mapping = RestoreMapping {
1282            members: vec![RestoreMappingEntry {
1283                source_canister: ROOT.to_string(),
1284                target_canister: ROOT.to_string(),
1285            }],
1286        };
1287
1288        let err = RestorePlanner::plan(&manifest, Some(&mapping))
1289            .expect_err("incomplete mapping should fail");
1290
1291        assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
1292    }
1293
1294    // Ensure mappings cannot silently include canisters outside the manifest.
1295    #[test]
1296    fn mapped_restore_rejects_unknown_mapping_sources() {
1297        let manifest = valid_manifest(IdentityMode::Relocatable);
1298        let unknown = "rdmx6-jaaaa-aaaaa-aaadq-cai";
1299        let mapping = RestoreMapping {
1300            members: vec![
1301                RestoreMappingEntry {
1302                    source_canister: ROOT.to_string(),
1303                    target_canister: ROOT.to_string(),
1304                },
1305                RestoreMappingEntry {
1306                    source_canister: CHILD.to_string(),
1307                    target_canister: TARGET.to_string(),
1308                },
1309                RestoreMappingEntry {
1310                    source_canister: unknown.to_string(),
1311                    target_canister: unknown.to_string(),
1312                },
1313            ],
1314        };
1315
1316        let err = RestorePlanner::plan(&manifest, Some(&mapping))
1317            .expect_err("unknown mapping source should fail");
1318
1319        assert!(matches!(err, RestorePlanError::UnknownMappingSource(_)));
1320    }
1321
1322    // Ensure duplicate target mappings fail before a plan is produced.
1323    #[test]
1324    fn duplicate_mapping_targets_fail_validation() {
1325        let manifest = valid_manifest(IdentityMode::Relocatable);
1326        let mapping = RestoreMapping {
1327            members: vec![
1328                RestoreMappingEntry {
1329                    source_canister: ROOT.to_string(),
1330                    target_canister: ROOT.to_string(),
1331                },
1332                RestoreMappingEntry {
1333                    source_canister: CHILD.to_string(),
1334                    target_canister: ROOT.to_string(),
1335                },
1336            ],
1337        };
1338
1339        let err = RestorePlanner::plan(&manifest, Some(&mapping))
1340            .expect_err("duplicate targets should fail");
1341
1342        assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
1343    }
1344}