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/// RestoreIdentitySummary
75///
76
77#[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///
89/// RestoreSnapshotSummary
90///
91
92#[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///
109/// RestoreVerificationSummary
110///
111
112#[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///
124/// RestoreReadinessSummary
125///
126
127#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
128pub struct RestoreReadinessSummary {
129    pub ready: bool,
130    pub reasons: Vec<String>,
131}
132
133///
134/// RestoreOperationSummary
135///
136
137#[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///
146/// RestoreOrderingSummary
147///
148
149#[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///
158/// RestorePhase
159///
160
161#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
162pub struct RestorePhase {
163    pub restore_group: u16,
164    pub members: Vec<RestorePlanMember>,
165}
166
167///
168/// RestorePlanMember
169///
170
171#[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///
188/// RestoreOrderingDependency
189///
190
191#[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///
199/// RestoreOrderingRelationship
200///
201
202#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
203#[serde(rename_all = "kebab-case")]
204pub enum RestoreOrderingRelationship {
205    ParentInSameGroup,
206    ParentInEarlierGroup,
207}
208
209///
210/// RestorePlanner
211///
212
213pub struct RestorePlanner;
214
215impl RestorePlanner {
216    /// Build a no-mutation restore plan from the manifest and optional target mapping.
217    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///
256/// RestorePlanError
257///
258
259#[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
302// Validate a user-supplied restore mapping before applying it to the manifest.
303fn 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
327// Ensure mappings only reference members declared in the manifest.
328fn 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
350// Resolve source manifest members into target restore members.
351fn 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
393// Resolve one member's target canister, enforcing identity continuity.
394fn 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
416// Summarize identity and mapping decisions before grouping restore phases.
417fn 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
452// Summarize snapshot provenance completeness before grouping restore phases.
453fn 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
483// Summarize whether restore planning has the metadata required for automation.
484fn 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
512// Summarize restore verification work declared by the manifest and members.
513fn 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
558// Summarize the concrete restore operations implied by a no-mutation plan.
559const 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
572// Reject group assignments that would restore a child before its parent.
573fn 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
602// Group members and apply parent-before-child ordering inside each group.
603fn 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
623// Topologically order one group using manifest parent relationships.
624fn 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
654// Describe the topology dependency that controlled a member's restore ordering.
655fn 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
674// Summarize the dependency ordering metadata exposed in the restore plan.
675fn 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
703// Determine whether a member's in-group parent has already been emitted.
704fn 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
715// Validate textual principal fields used in mappings.
716fn 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    // Build one valid manifest with a parent and child in the same restore group.
741    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    // Build one manifest member for restore planning tests.
785    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    // Ensure in-place restore planning sorts parent before child.
819    #[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    // Ensure cross-group parent dependencies are exposed when the parent phase is earlier.
864    #[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    // Ensure restore planning fails when groups would restore a child before its parent.
891    #[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    // Ensure fixed identities cannot be remapped.
907    #[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    // Ensure relocatable identities may be mapped when all members are covered.
930    #[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    // Ensure restore plans carry enough metadata for operator preflight.
963    #[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    // Ensure restore plans make mapping mode explicit.
982    #[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    // Ensure restore plans summarize snapshot provenance completeness.
1012    #[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    // Ensure restore plans summarize manifest-level verification work.
1041    #[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    // Ensure restore plans summarize the concrete operation counts automation will schedule.
1073    #[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    // Ensure role-level verification checks are counted once per matching member.
1086    #[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    // Ensure mapped restores must cover every source member.
1118    #[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    // Ensure mappings cannot silently include canisters outside the manifest.
1135    #[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    // Ensure duplicate target mappings fail before a plan is produced.
1163    #[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}