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/// RestoreApplyDryRun
190///
191
192#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
193pub struct RestoreApplyDryRun {
194    pub dry_run_version: u16,
195    pub backup_id: String,
196    pub ready: bool,
197    pub readiness_reasons: Vec<String>,
198    pub member_count: usize,
199    pub phase_count: usize,
200    pub status_supplied: bool,
201    pub planned_snapshot_loads: usize,
202    pub planned_code_reinstalls: usize,
203    pub planned_verification_checks: usize,
204    pub rendered_operations: usize,
205    pub phases: Vec<RestoreApplyDryRunPhase>,
206}
207
208impl RestoreApplyDryRun {
209    /// Build a no-mutation apply dry-run after validating optional status identity.
210    pub fn try_from_plan(
211        plan: &RestorePlan,
212        status: Option<&RestoreStatus>,
213    ) -> Result<Self, RestoreApplyDryRunError> {
214        if let Some(status) = status {
215            validate_restore_status_matches_plan(plan, status)?;
216        }
217
218        Ok(Self::from_validated_plan(plan, status))
219    }
220
221    // Build a no-mutation apply dry-run after any supplied status is validated.
222    fn from_validated_plan(plan: &RestorePlan, status: Option<&RestoreStatus>) -> Self {
223        let mut next_sequence = 0;
224        let phases = plan
225            .phases
226            .iter()
227            .map(|phase| RestoreApplyDryRunPhase::from_plan_phase(phase, &mut next_sequence))
228            .collect::<Vec<_>>();
229        let rendered_operations = phases
230            .iter()
231            .map(|phase| phase.operations.len())
232            .sum::<usize>();
233
234        Self {
235            dry_run_version: 1,
236            backup_id: plan.backup_id.clone(),
237            ready: status.map_or(plan.readiness_summary.ready, |status| status.ready),
238            readiness_reasons: status.map_or_else(
239                || plan.readiness_summary.reasons.clone(),
240                |status| status.readiness_reasons.clone(),
241            ),
242            member_count: plan.member_count,
243            phase_count: plan.ordering_summary.phase_count,
244            status_supplied: status.is_some(),
245            planned_snapshot_loads: plan.operation_summary.planned_snapshot_loads,
246            planned_code_reinstalls: plan.operation_summary.planned_code_reinstalls,
247            planned_verification_checks: plan.operation_summary.planned_verification_checks,
248            rendered_operations,
249            phases,
250        }
251    }
252}
253
254// Validate that a supplied restore status belongs to the restore plan.
255fn validate_restore_status_matches_plan(
256    plan: &RestorePlan,
257    status: &RestoreStatus,
258) -> Result<(), RestoreApplyDryRunError> {
259    validate_status_string_field("backup_id", &plan.backup_id, &status.backup_id)?;
260    validate_status_string_field(
261        "source_environment",
262        &plan.source_environment,
263        &status.source_environment,
264    )?;
265    validate_status_string_field(
266        "source_root_canister",
267        &plan.source_root_canister,
268        &status.source_root_canister,
269    )?;
270    validate_status_string_field("topology_hash", &plan.topology_hash, &status.topology_hash)?;
271    validate_status_usize_field("member_count", plan.member_count, status.member_count)?;
272    validate_status_usize_field(
273        "phase_count",
274        plan.ordering_summary.phase_count,
275        status.phase_count,
276    )?;
277    Ok(())
278}
279
280// Validate one string field shared by restore plan and status.
281fn validate_status_string_field(
282    field: &'static str,
283    plan: &str,
284    status: &str,
285) -> Result<(), RestoreApplyDryRunError> {
286    if plan == status {
287        return Ok(());
288    }
289
290    Err(RestoreApplyDryRunError::StatusPlanMismatch {
291        field,
292        plan: plan.to_string(),
293        status: status.to_string(),
294    })
295}
296
297// Validate one numeric field shared by restore plan and status.
298const fn validate_status_usize_field(
299    field: &'static str,
300    plan: usize,
301    status: usize,
302) -> Result<(), RestoreApplyDryRunError> {
303    if plan == status {
304        return Ok(());
305    }
306
307    Err(RestoreApplyDryRunError::StatusPlanCountMismatch {
308        field,
309        plan,
310        status,
311    })
312}
313
314///
315/// RestoreApplyDryRunPhase
316///
317
318#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
319pub struct RestoreApplyDryRunPhase {
320    pub restore_group: u16,
321    pub operations: Vec<RestoreApplyDryRunOperation>,
322}
323
324impl RestoreApplyDryRunPhase {
325    // Build one dry-run phase from one restore plan phase.
326    fn from_plan_phase(phase: &RestorePhase, next_sequence: &mut usize) -> Self {
327        let mut operations = Vec::new();
328
329        for member in &phase.members {
330            push_member_operation(
331                &mut operations,
332                next_sequence,
333                RestoreApplyOperationKind::UploadSnapshot,
334                member,
335                None,
336            );
337            push_member_operation(
338                &mut operations,
339                next_sequence,
340                RestoreApplyOperationKind::LoadSnapshot,
341                member,
342                None,
343            );
344            push_member_operation(
345                &mut operations,
346                next_sequence,
347                RestoreApplyOperationKind::ReinstallCode,
348                member,
349                None,
350            );
351
352            for check in &member.verification_checks {
353                push_member_operation(
354                    &mut operations,
355                    next_sequence,
356                    RestoreApplyOperationKind::VerifyMember,
357                    member,
358                    Some(check),
359                );
360            }
361        }
362
363        Self {
364            restore_group: phase.restore_group,
365            operations,
366        }
367    }
368}
369
370// Append one member-level dry-run operation using the current phase order.
371fn push_member_operation(
372    operations: &mut Vec<RestoreApplyDryRunOperation>,
373    next_sequence: &mut usize,
374    operation: RestoreApplyOperationKind,
375    member: &RestorePlanMember,
376    check: Option<&VerificationCheck>,
377) {
378    let sequence = *next_sequence;
379    *next_sequence += 1;
380
381    operations.push(RestoreApplyDryRunOperation {
382        sequence,
383        operation,
384        restore_group: member.restore_group,
385        phase_order: member.phase_order,
386        source_canister: member.source_canister.clone(),
387        target_canister: member.target_canister.clone(),
388        role: member.role.clone(),
389        snapshot_id: Some(member.source_snapshot.snapshot_id.clone()),
390        artifact_path: Some(member.source_snapshot.artifact_path.clone()),
391        verification_kind: check.map(|check| check.kind.clone()),
392        verification_method: check.and_then(|check| check.method.clone()),
393    });
394}
395
396///
397/// RestoreApplyDryRunOperation
398///
399
400#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
401pub struct RestoreApplyDryRunOperation {
402    pub sequence: usize,
403    pub operation: RestoreApplyOperationKind,
404    pub restore_group: u16,
405    pub phase_order: usize,
406    pub source_canister: String,
407    pub target_canister: String,
408    pub role: String,
409    pub snapshot_id: Option<String>,
410    pub artifact_path: Option<String>,
411    pub verification_kind: Option<String>,
412    pub verification_method: Option<String>,
413}
414
415///
416/// RestoreApplyOperationKind
417///
418
419#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
420#[serde(rename_all = "kebab-case")]
421pub enum RestoreApplyOperationKind {
422    UploadSnapshot,
423    LoadSnapshot,
424    ReinstallCode,
425    VerifyMember,
426}
427
428///
429/// RestoreApplyDryRunError
430///
431
432#[derive(Debug, ThisError)]
433pub enum RestoreApplyDryRunError {
434    #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
435    StatusPlanMismatch {
436        field: &'static str,
437        plan: String,
438        status: String,
439    },
440
441    #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
442    StatusPlanCountMismatch {
443        field: &'static str,
444        plan: usize,
445        status: usize,
446    },
447}
448
449///
450/// RestoreIdentitySummary
451///
452
453#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
454pub struct RestoreIdentitySummary {
455    pub mapping_supplied: bool,
456    pub all_sources_mapped: bool,
457    pub fixed_members: usize,
458    pub relocatable_members: usize,
459    pub in_place_members: usize,
460    pub mapped_members: usize,
461    pub remapped_members: usize,
462}
463
464///
465/// RestoreSnapshotSummary
466///
467
468#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
469#[expect(
470    clippy::struct_excessive_bools,
471    reason = "restore summaries intentionally expose machine-readable readiness flags"
472)]
473pub struct RestoreSnapshotSummary {
474    pub all_members_have_module_hash: bool,
475    pub all_members_have_wasm_hash: bool,
476    pub all_members_have_code_version: bool,
477    pub all_members_have_checksum: bool,
478    pub members_with_module_hash: usize,
479    pub members_with_wasm_hash: usize,
480    pub members_with_code_version: usize,
481    pub members_with_checksum: usize,
482}
483
484///
485/// RestoreVerificationSummary
486///
487
488#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
489pub struct RestoreVerificationSummary {
490    pub verification_required: bool,
491    pub all_members_have_checks: bool,
492    pub fleet_checks: usize,
493    pub member_check_groups: usize,
494    pub member_checks: usize,
495    pub members_with_checks: usize,
496    pub total_checks: usize,
497}
498
499///
500/// RestoreReadinessSummary
501///
502
503#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
504pub struct RestoreReadinessSummary {
505    pub ready: bool,
506    pub reasons: Vec<String>,
507}
508
509///
510/// RestoreOperationSummary
511///
512
513#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
514pub struct RestoreOperationSummary {
515    pub planned_snapshot_loads: usize,
516    pub planned_code_reinstalls: usize,
517    pub planned_verification_checks: usize,
518    pub planned_phases: usize,
519}
520
521///
522/// RestoreOrderingSummary
523///
524
525#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
526pub struct RestoreOrderingSummary {
527    pub phase_count: usize,
528    pub dependency_free_members: usize,
529    pub in_group_parent_edges: usize,
530    pub cross_group_parent_edges: usize,
531}
532
533///
534/// RestorePhase
535///
536
537#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
538pub struct RestorePhase {
539    pub restore_group: u16,
540    pub members: Vec<RestorePlanMember>,
541}
542
543///
544/// RestorePlanMember
545///
546
547#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
548pub struct RestorePlanMember {
549    pub source_canister: String,
550    pub target_canister: String,
551    pub role: String,
552    pub parent_source_canister: Option<String>,
553    pub parent_target_canister: Option<String>,
554    pub ordering_dependency: Option<RestoreOrderingDependency>,
555    pub phase_order: usize,
556    pub restore_group: u16,
557    pub identity_mode: IdentityMode,
558    pub verification_class: String,
559    pub verification_checks: Vec<VerificationCheck>,
560    pub source_snapshot: SourceSnapshot,
561}
562
563///
564/// RestoreOrderingDependency
565///
566
567#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
568pub struct RestoreOrderingDependency {
569    pub source_canister: String,
570    pub target_canister: String,
571    pub relationship: RestoreOrderingRelationship,
572}
573
574///
575/// RestoreOrderingRelationship
576///
577
578#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
579#[serde(rename_all = "kebab-case")]
580pub enum RestoreOrderingRelationship {
581    ParentInSameGroup,
582    ParentInEarlierGroup,
583}
584
585///
586/// RestorePlanner
587///
588
589pub struct RestorePlanner;
590
591impl RestorePlanner {
592    /// Build a no-mutation restore plan from the manifest and optional target mapping.
593    pub fn plan(
594        manifest: &FleetBackupManifest,
595        mapping: Option<&RestoreMapping>,
596    ) -> Result<RestorePlan, RestorePlanError> {
597        manifest.validate()?;
598        if let Some(mapping) = mapping {
599            validate_mapping(mapping)?;
600            validate_mapping_sources(manifest, mapping)?;
601        }
602
603        let members = resolve_members(manifest, mapping)?;
604        let identity_summary = restore_identity_summary(&members, mapping.is_some());
605        let snapshot_summary = restore_snapshot_summary(&members);
606        let verification_summary = restore_verification_summary(manifest, &members);
607        let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
608        validate_restore_group_dependencies(&members)?;
609        let phases = group_and_order_members(members)?;
610        let ordering_summary = restore_ordering_summary(&phases);
611        let operation_summary =
612            restore_operation_summary(manifest.fleet.members.len(), &verification_summary, &phases);
613
614        Ok(RestorePlan {
615            backup_id: manifest.backup_id.clone(),
616            source_environment: manifest.source.environment.clone(),
617            source_root_canister: manifest.source.root_canister.clone(),
618            topology_hash: manifest.fleet.topology_hash.clone(),
619            member_count: manifest.fleet.members.len(),
620            identity_summary,
621            snapshot_summary,
622            verification_summary,
623            readiness_summary,
624            operation_summary,
625            ordering_summary,
626            phases,
627        })
628    }
629}
630
631///
632/// RestorePlanError
633///
634
635#[derive(Debug, ThisError)]
636pub enum RestorePlanError {
637    #[error(transparent)]
638    InvalidManifest(#[from] ManifestValidationError),
639
640    #[error("field {field} must be a valid principal: {value}")]
641    InvalidPrincipal { field: &'static str, value: String },
642
643    #[error("mapping contains duplicate source canister {0}")]
644    DuplicateMappingSource(String),
645
646    #[error("mapping contains duplicate target canister {0}")]
647    DuplicateMappingTarget(String),
648
649    #[error("mapping references unknown source canister {0}")]
650    UnknownMappingSource(String),
651
652    #[error("mapping is missing source canister {0}")]
653    MissingMappingSource(String),
654
655    #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
656    FixedIdentityRemap {
657        source_canister: String,
658        target_canister: String,
659    },
660
661    #[error("restore plan contains duplicate target canister {0}")]
662    DuplicatePlanTarget(String),
663
664    #[error("restore group {0} contains a parent cycle or unresolved dependency")]
665    RestoreOrderCycle(u16),
666
667    #[error(
668        "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
669    )]
670    ParentRestoreGroupAfterChild {
671        child_source_canister: String,
672        parent_source_canister: String,
673        child_restore_group: u16,
674        parent_restore_group: u16,
675    },
676}
677
678// Validate a user-supplied restore mapping before applying it to the manifest.
679fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
680    let mut sources = BTreeSet::new();
681    let mut targets = BTreeSet::new();
682
683    for entry in &mapping.members {
684        validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
685        validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
686
687        if !sources.insert(entry.source_canister.clone()) {
688            return Err(RestorePlanError::DuplicateMappingSource(
689                entry.source_canister.clone(),
690            ));
691        }
692
693        if !targets.insert(entry.target_canister.clone()) {
694            return Err(RestorePlanError::DuplicateMappingTarget(
695                entry.target_canister.clone(),
696            ));
697        }
698    }
699
700    Ok(())
701}
702
703// Ensure mappings only reference members declared in the manifest.
704fn validate_mapping_sources(
705    manifest: &FleetBackupManifest,
706    mapping: &RestoreMapping,
707) -> Result<(), RestorePlanError> {
708    let sources = manifest
709        .fleet
710        .members
711        .iter()
712        .map(|member| member.canister_id.as_str())
713        .collect::<BTreeSet<_>>();
714
715    for entry in &mapping.members {
716        if !sources.contains(entry.source_canister.as_str()) {
717            return Err(RestorePlanError::UnknownMappingSource(
718                entry.source_canister.clone(),
719            ));
720        }
721    }
722
723    Ok(())
724}
725
726// Resolve source manifest members into target restore members.
727fn resolve_members(
728    manifest: &FleetBackupManifest,
729    mapping: Option<&RestoreMapping>,
730) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
731    let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
732    let mut targets = BTreeSet::new();
733    let mut source_to_target = BTreeMap::new();
734
735    for member in &manifest.fleet.members {
736        let target = resolve_target(member, mapping)?;
737        if !targets.insert(target.clone()) {
738            return Err(RestorePlanError::DuplicatePlanTarget(target));
739        }
740
741        source_to_target.insert(member.canister_id.clone(), target.clone());
742        plan_members.push(RestorePlanMember {
743            source_canister: member.canister_id.clone(),
744            target_canister: target,
745            role: member.role.clone(),
746            parent_source_canister: member.parent_canister_id.clone(),
747            parent_target_canister: None,
748            ordering_dependency: None,
749            phase_order: 0,
750            restore_group: member.restore_group,
751            identity_mode: member.identity_mode.clone(),
752            verification_class: member.verification_class.clone(),
753            verification_checks: member.verification_checks.clone(),
754            source_snapshot: member.source_snapshot.clone(),
755        });
756    }
757
758    for member in &mut plan_members {
759        member.parent_target_canister = member
760            .parent_source_canister
761            .as_ref()
762            .and_then(|parent| source_to_target.get(parent))
763            .cloned();
764    }
765
766    Ok(plan_members)
767}
768
769// Resolve one member's target canister, enforcing identity continuity.
770fn resolve_target(
771    member: &FleetMember,
772    mapping: Option<&RestoreMapping>,
773) -> Result<String, RestorePlanError> {
774    let target = match mapping {
775        Some(mapping) => mapping
776            .target_for(&member.canister_id)
777            .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
778            .to_string(),
779        None => member.canister_id.clone(),
780    };
781
782    if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
783        return Err(RestorePlanError::FixedIdentityRemap {
784            source_canister: member.canister_id.clone(),
785            target_canister: target,
786        });
787    }
788
789    Ok(target)
790}
791
792// Summarize identity and mapping decisions before grouping restore phases.
793fn restore_identity_summary(
794    members: &[RestorePlanMember],
795    mapping_supplied: bool,
796) -> RestoreIdentitySummary {
797    let mut summary = RestoreIdentitySummary {
798        mapping_supplied,
799        all_sources_mapped: false,
800        fixed_members: 0,
801        relocatable_members: 0,
802        in_place_members: 0,
803        mapped_members: 0,
804        remapped_members: 0,
805    };
806
807    for member in members {
808        match member.identity_mode {
809            IdentityMode::Fixed => summary.fixed_members += 1,
810            IdentityMode::Relocatable => summary.relocatable_members += 1,
811        }
812
813        if member.source_canister == member.target_canister {
814            summary.in_place_members += 1;
815        } else {
816            summary.remapped_members += 1;
817        }
818        if mapping_supplied {
819            summary.mapped_members += 1;
820        }
821    }
822
823    summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
824
825    summary
826}
827
828// Summarize snapshot provenance completeness before grouping restore phases.
829fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
830    let members_with_module_hash = members
831        .iter()
832        .filter(|member| member.source_snapshot.module_hash.is_some())
833        .count();
834    let members_with_wasm_hash = members
835        .iter()
836        .filter(|member| member.source_snapshot.wasm_hash.is_some())
837        .count();
838    let members_with_code_version = members
839        .iter()
840        .filter(|member| member.source_snapshot.code_version.is_some())
841        .count();
842    let members_with_checksum = members
843        .iter()
844        .filter(|member| member.source_snapshot.checksum.is_some())
845        .count();
846
847    RestoreSnapshotSummary {
848        all_members_have_module_hash: members_with_module_hash == members.len(),
849        all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
850        all_members_have_code_version: members_with_code_version == members.len(),
851        all_members_have_checksum: members_with_checksum == members.len(),
852        members_with_module_hash,
853        members_with_wasm_hash,
854        members_with_code_version,
855        members_with_checksum,
856    }
857}
858
859// Summarize whether restore planning has the metadata required for automation.
860fn restore_readiness_summary(
861    snapshot: &RestoreSnapshotSummary,
862    verification: &RestoreVerificationSummary,
863) -> RestoreReadinessSummary {
864    let mut reasons = Vec::new();
865
866    if !snapshot.all_members_have_module_hash {
867        reasons.push("missing-module-hash".to_string());
868    }
869    if !snapshot.all_members_have_wasm_hash {
870        reasons.push("missing-wasm-hash".to_string());
871    }
872    if !snapshot.all_members_have_code_version {
873        reasons.push("missing-code-version".to_string());
874    }
875    if !snapshot.all_members_have_checksum {
876        reasons.push("missing-snapshot-checksum".to_string());
877    }
878    if !verification.all_members_have_checks {
879        reasons.push("missing-verification-checks".to_string());
880    }
881
882    RestoreReadinessSummary {
883        ready: reasons.is_empty(),
884        reasons,
885    }
886}
887
888// Summarize restore verification work declared by the manifest and members.
889fn restore_verification_summary(
890    manifest: &FleetBackupManifest,
891    members: &[RestorePlanMember],
892) -> RestoreVerificationSummary {
893    let fleet_checks = manifest.verification.fleet_checks.len();
894    let member_check_groups = manifest.verification.member_checks.len();
895    let role_check_counts = manifest
896        .verification
897        .member_checks
898        .iter()
899        .map(|group| (group.role.as_str(), group.checks.len()))
900        .collect::<BTreeMap<_, _>>();
901    let inline_member_checks = members
902        .iter()
903        .map(|member| member.verification_checks.len())
904        .sum::<usize>();
905    let role_member_checks = members
906        .iter()
907        .map(|member| {
908            role_check_counts
909                .get(member.role.as_str())
910                .copied()
911                .unwrap_or(0)
912        })
913        .sum::<usize>();
914    let member_checks = inline_member_checks + role_member_checks;
915    let members_with_checks = members
916        .iter()
917        .filter(|member| {
918            !member.verification_checks.is_empty()
919                || role_check_counts.contains_key(member.role.as_str())
920        })
921        .count();
922
923    RestoreVerificationSummary {
924        verification_required: true,
925        all_members_have_checks: members_with_checks == members.len(),
926        fleet_checks,
927        member_check_groups,
928        member_checks,
929        members_with_checks,
930        total_checks: fleet_checks + member_checks,
931    }
932}
933
934// Summarize the concrete restore operations implied by a no-mutation plan.
935const fn restore_operation_summary(
936    member_count: usize,
937    verification_summary: &RestoreVerificationSummary,
938    phases: &[RestorePhase],
939) -> RestoreOperationSummary {
940    RestoreOperationSummary {
941        planned_snapshot_loads: member_count,
942        planned_code_reinstalls: member_count,
943        planned_verification_checks: verification_summary.total_checks,
944        planned_phases: phases.len(),
945    }
946}
947
948// Reject group assignments that would restore a child before its parent.
949fn validate_restore_group_dependencies(
950    members: &[RestorePlanMember],
951) -> Result<(), RestorePlanError> {
952    let groups_by_source = members
953        .iter()
954        .map(|member| (member.source_canister.as_str(), member.restore_group))
955        .collect::<BTreeMap<_, _>>();
956
957    for member in members {
958        let Some(parent) = &member.parent_source_canister else {
959            continue;
960        };
961        let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
962            continue;
963        };
964
965        if *parent_group > member.restore_group {
966            return Err(RestorePlanError::ParentRestoreGroupAfterChild {
967                child_source_canister: member.source_canister.clone(),
968                parent_source_canister: parent.clone(),
969                child_restore_group: member.restore_group,
970                parent_restore_group: *parent_group,
971            });
972        }
973    }
974
975    Ok(())
976}
977
978// Group members and apply parent-before-child ordering inside each group.
979fn group_and_order_members(
980    members: Vec<RestorePlanMember>,
981) -> Result<Vec<RestorePhase>, RestorePlanError> {
982    let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
983    for member in members {
984        groups.entry(member.restore_group).or_default().push(member);
985    }
986
987    groups
988        .into_iter()
989        .map(|(restore_group, members)| {
990            let members = order_group(restore_group, members)?;
991            Ok(RestorePhase {
992                restore_group,
993                members,
994            })
995        })
996        .collect()
997}
998
999// Topologically order one group using manifest parent relationships.
1000fn order_group(
1001    restore_group: u16,
1002    members: Vec<RestorePlanMember>,
1003) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
1004    let mut remaining = members;
1005    let group_sources = remaining
1006        .iter()
1007        .map(|member| member.source_canister.clone())
1008        .collect::<BTreeSet<_>>();
1009    let mut emitted = BTreeSet::new();
1010    let mut ordered = Vec::with_capacity(remaining.len());
1011
1012    while !remaining.is_empty() {
1013        let Some(index) = remaining
1014            .iter()
1015            .position(|member| parent_satisfied(member, &group_sources, &emitted))
1016        else {
1017            return Err(RestorePlanError::RestoreOrderCycle(restore_group));
1018        };
1019
1020        let mut member = remaining.remove(index);
1021        member.phase_order = ordered.len();
1022        member.ordering_dependency = ordering_dependency(&member, &group_sources);
1023        emitted.insert(member.source_canister.clone());
1024        ordered.push(member);
1025    }
1026
1027    Ok(ordered)
1028}
1029
1030// Describe the topology dependency that controlled a member's restore ordering.
1031fn ordering_dependency(
1032    member: &RestorePlanMember,
1033    group_sources: &BTreeSet<String>,
1034) -> Option<RestoreOrderingDependency> {
1035    let parent_source = member.parent_source_canister.as_ref()?;
1036    let parent_target = member.parent_target_canister.as_ref()?;
1037    let relationship = if group_sources.contains(parent_source) {
1038        RestoreOrderingRelationship::ParentInSameGroup
1039    } else {
1040        RestoreOrderingRelationship::ParentInEarlierGroup
1041    };
1042
1043    Some(RestoreOrderingDependency {
1044        source_canister: parent_source.clone(),
1045        target_canister: parent_target.clone(),
1046        relationship,
1047    })
1048}
1049
1050// Summarize the dependency ordering metadata exposed in the restore plan.
1051fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
1052    let mut summary = RestoreOrderingSummary {
1053        phase_count: phases.len(),
1054        dependency_free_members: 0,
1055        in_group_parent_edges: 0,
1056        cross_group_parent_edges: 0,
1057    };
1058
1059    for member in phases.iter().flat_map(|phase| phase.members.iter()) {
1060        match &member.ordering_dependency {
1061            Some(dependency)
1062                if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
1063            {
1064                summary.in_group_parent_edges += 1;
1065            }
1066            Some(dependency)
1067                if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
1068            {
1069                summary.cross_group_parent_edges += 1;
1070            }
1071            Some(_) => {}
1072            None => summary.dependency_free_members += 1,
1073        }
1074    }
1075
1076    summary
1077}
1078
1079// Determine whether a member's in-group parent has already been emitted.
1080fn parent_satisfied(
1081    member: &RestorePlanMember,
1082    group_sources: &BTreeSet<String>,
1083    emitted: &BTreeSet<String>,
1084) -> bool {
1085    match &member.parent_source_canister {
1086        Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
1087        _ => true,
1088    }
1089}
1090
1091// Validate textual principal fields used in mappings.
1092fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
1093    Principal::from_str(value)
1094        .map(|_| ())
1095        .map_err(|_| RestorePlanError::InvalidPrincipal {
1096            field,
1097            value: value.to_string(),
1098        })
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103    use super::*;
1104    use crate::manifest::{
1105        BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetSection,
1106        MemberVerificationChecks, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
1107        VerificationPlan,
1108    };
1109
1110    const ROOT: &str = "aaaaa-aa";
1111    const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
1112    const CHILD_TWO: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";
1113    const TARGET: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
1114    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1115
1116    // Build one valid manifest with a parent and child in the same restore group.
1117    fn valid_manifest(identity_mode: IdentityMode) -> FleetBackupManifest {
1118        FleetBackupManifest {
1119            manifest_version: 1,
1120            backup_id: "fbk_test_001".to_string(),
1121            created_at: "2026-04-10T12:00:00Z".to_string(),
1122            tool: ToolMetadata {
1123                name: "canic".to_string(),
1124                version: "v1".to_string(),
1125            },
1126            source: SourceMetadata {
1127                environment: "local".to_string(),
1128                root_canister: ROOT.to_string(),
1129            },
1130            consistency: ConsistencySection {
1131                mode: ConsistencyMode::CrashConsistent,
1132                backup_units: vec![BackupUnit {
1133                    unit_id: "whole-fleet".to_string(),
1134                    kind: BackupUnitKind::WholeFleet,
1135                    roles: vec!["root".to_string(), "app".to_string()],
1136                    consistency_reason: None,
1137                    dependency_closure: Vec::new(),
1138                    topology_validation: "subtree-closed".to_string(),
1139                    quiescence_strategy: None,
1140                }],
1141            },
1142            fleet: FleetSection {
1143                topology_hash_algorithm: "sha256".to_string(),
1144                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1145                discovery_topology_hash: HASH.to_string(),
1146                pre_snapshot_topology_hash: HASH.to_string(),
1147                topology_hash: HASH.to_string(),
1148                members: vec![
1149                    fleet_member("app", CHILD, Some(ROOT), identity_mode, 1),
1150                    fleet_member("root", ROOT, None, IdentityMode::Fixed, 1),
1151                ],
1152            },
1153            verification: VerificationPlan {
1154                fleet_checks: Vec::new(),
1155                member_checks: Vec::new(),
1156            },
1157        }
1158    }
1159
1160    // Build one manifest member for restore planning tests.
1161    fn fleet_member(
1162        role: &str,
1163        canister_id: &str,
1164        parent_canister_id: Option<&str>,
1165        identity_mode: IdentityMode,
1166        restore_group: u16,
1167    ) -> FleetMember {
1168        FleetMember {
1169            role: role.to_string(),
1170            canister_id: canister_id.to_string(),
1171            parent_canister_id: parent_canister_id.map(str::to_string),
1172            subnet_canister_id: None,
1173            controller_hint: Some(ROOT.to_string()),
1174            identity_mode,
1175            restore_group,
1176            verification_class: "basic".to_string(),
1177            verification_checks: vec![VerificationCheck {
1178                kind: "call".to_string(),
1179                method: Some("canic_ready".to_string()),
1180                roles: Vec::new(),
1181            }],
1182            source_snapshot: SourceSnapshot {
1183                snapshot_id: format!("snap-{role}"),
1184                module_hash: Some(HASH.to_string()),
1185                wasm_hash: Some(HASH.to_string()),
1186                code_version: Some("v0.30.0".to_string()),
1187                artifact_path: format!("artifacts/{role}"),
1188                checksum_algorithm: "sha256".to_string(),
1189                checksum: Some(HASH.to_string()),
1190            },
1191        }
1192    }
1193
1194    // Ensure in-place restore planning sorts parent before child.
1195    #[test]
1196    fn in_place_plan_orders_parent_before_child() {
1197        let manifest = valid_manifest(IdentityMode::Relocatable);
1198
1199        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1200        let ordered = plan.ordered_members();
1201
1202        assert_eq!(plan.backup_id, "fbk_test_001");
1203        assert_eq!(plan.source_environment, "local");
1204        assert_eq!(plan.source_root_canister, ROOT);
1205        assert_eq!(plan.topology_hash, HASH);
1206        assert_eq!(plan.member_count, 2);
1207        assert_eq!(plan.identity_summary.fixed_members, 1);
1208        assert_eq!(plan.identity_summary.relocatable_members, 1);
1209        assert_eq!(plan.identity_summary.in_place_members, 2);
1210        assert_eq!(plan.identity_summary.mapped_members, 0);
1211        assert_eq!(plan.identity_summary.remapped_members, 0);
1212        assert!(plan.verification_summary.verification_required);
1213        assert!(plan.verification_summary.all_members_have_checks);
1214        assert!(plan.readiness_summary.ready);
1215        assert!(plan.readiness_summary.reasons.is_empty());
1216        assert_eq!(plan.verification_summary.fleet_checks, 0);
1217        assert_eq!(plan.verification_summary.member_check_groups, 0);
1218        assert_eq!(plan.verification_summary.member_checks, 2);
1219        assert_eq!(plan.verification_summary.members_with_checks, 2);
1220        assert_eq!(plan.verification_summary.total_checks, 2);
1221        assert_eq!(plan.ordering_summary.phase_count, 1);
1222        assert_eq!(plan.ordering_summary.dependency_free_members, 1);
1223        assert_eq!(plan.ordering_summary.in_group_parent_edges, 1);
1224        assert_eq!(plan.ordering_summary.cross_group_parent_edges, 0);
1225        assert_eq!(ordered[0].phase_order, 0);
1226        assert_eq!(ordered[1].phase_order, 1);
1227        assert_eq!(ordered[0].source_canister, ROOT);
1228        assert_eq!(ordered[1].source_canister, CHILD);
1229        assert_eq!(
1230            ordered[1].ordering_dependency,
1231            Some(RestoreOrderingDependency {
1232                source_canister: ROOT.to_string(),
1233                target_canister: ROOT.to_string(),
1234                relationship: RestoreOrderingRelationship::ParentInSameGroup,
1235            })
1236        );
1237    }
1238
1239    // Ensure cross-group parent dependencies are exposed when the parent phase is earlier.
1240    #[test]
1241    fn plan_reports_parent_dependency_from_earlier_group() {
1242        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1243        manifest.fleet.members[0].restore_group = 2;
1244        manifest.fleet.members[1].restore_group = 1;
1245
1246        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1247        let ordered = plan.ordered_members();
1248
1249        assert_eq!(plan.phases.len(), 2);
1250        assert_eq!(plan.ordering_summary.phase_count, 2);
1251        assert_eq!(plan.ordering_summary.dependency_free_members, 1);
1252        assert_eq!(plan.ordering_summary.in_group_parent_edges, 0);
1253        assert_eq!(plan.ordering_summary.cross_group_parent_edges, 1);
1254        assert_eq!(ordered[0].source_canister, ROOT);
1255        assert_eq!(ordered[1].source_canister, CHILD);
1256        assert_eq!(
1257            ordered[1].ordering_dependency,
1258            Some(RestoreOrderingDependency {
1259                source_canister: ROOT.to_string(),
1260                target_canister: ROOT.to_string(),
1261                relationship: RestoreOrderingRelationship::ParentInEarlierGroup,
1262            })
1263        );
1264    }
1265
1266    // Ensure restore planning fails when groups would restore a child before its parent.
1267    #[test]
1268    fn plan_rejects_parent_in_later_restore_group() {
1269        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1270        manifest.fleet.members[0].restore_group = 1;
1271        manifest.fleet.members[1].restore_group = 2;
1272
1273        let err = RestorePlanner::plan(&manifest, None)
1274            .expect_err("parent-after-child group ordering should fail");
1275
1276        assert!(matches!(
1277            err,
1278            RestorePlanError::ParentRestoreGroupAfterChild { .. }
1279        ));
1280    }
1281
1282    // Ensure fixed identities cannot be remapped.
1283    #[test]
1284    fn fixed_identity_member_cannot_be_remapped() {
1285        let manifest = valid_manifest(IdentityMode::Fixed);
1286        let mapping = RestoreMapping {
1287            members: vec![
1288                RestoreMappingEntry {
1289                    source_canister: ROOT.to_string(),
1290                    target_canister: ROOT.to_string(),
1291                },
1292                RestoreMappingEntry {
1293                    source_canister: CHILD.to_string(),
1294                    target_canister: TARGET.to_string(),
1295                },
1296            ],
1297        };
1298
1299        let err = RestorePlanner::plan(&manifest, Some(&mapping))
1300            .expect_err("fixed member remap should fail");
1301
1302        assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
1303    }
1304
1305    // Ensure relocatable identities may be mapped when all members are covered.
1306    #[test]
1307    fn relocatable_member_can_be_mapped() {
1308        let manifest = valid_manifest(IdentityMode::Relocatable);
1309        let mapping = RestoreMapping {
1310            members: vec![
1311                RestoreMappingEntry {
1312                    source_canister: ROOT.to_string(),
1313                    target_canister: ROOT.to_string(),
1314                },
1315                RestoreMappingEntry {
1316                    source_canister: CHILD.to_string(),
1317                    target_canister: TARGET.to_string(),
1318                },
1319            ],
1320        };
1321
1322        let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
1323        let child = plan
1324            .ordered_members()
1325            .into_iter()
1326            .find(|member| member.source_canister == CHILD)
1327            .expect("child member should be planned");
1328
1329        assert_eq!(plan.identity_summary.fixed_members, 1);
1330        assert_eq!(plan.identity_summary.relocatable_members, 1);
1331        assert_eq!(plan.identity_summary.in_place_members, 1);
1332        assert_eq!(plan.identity_summary.mapped_members, 2);
1333        assert_eq!(plan.identity_summary.remapped_members, 1);
1334        assert_eq!(child.target_canister, TARGET);
1335        assert_eq!(child.parent_target_canister, Some(ROOT.to_string()));
1336    }
1337
1338    // Ensure restore plans carry enough metadata for operator preflight.
1339    #[test]
1340    fn plan_members_include_snapshot_and_verification_metadata() {
1341        let manifest = valid_manifest(IdentityMode::Relocatable);
1342
1343        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1344        let root = plan
1345            .ordered_members()
1346            .into_iter()
1347            .find(|member| member.source_canister == ROOT)
1348            .expect("root member should be planned");
1349
1350        assert_eq!(root.identity_mode, IdentityMode::Fixed);
1351        assert_eq!(root.verification_class, "basic");
1352        assert_eq!(root.verification_checks[0].kind, "call");
1353        assert_eq!(root.source_snapshot.snapshot_id, "snap-root");
1354        assert_eq!(root.source_snapshot.artifact_path, "artifacts/root");
1355    }
1356
1357    // Ensure restore plans make mapping mode explicit.
1358    #[test]
1359    fn plan_includes_mapping_summary() {
1360        let manifest = valid_manifest(IdentityMode::Relocatable);
1361        let in_place = RestorePlanner::plan(&manifest, None).expect("plan should build");
1362
1363        assert!(!in_place.identity_summary.mapping_supplied);
1364        assert!(!in_place.identity_summary.all_sources_mapped);
1365        assert_eq!(in_place.identity_summary.mapped_members, 0);
1366
1367        let mapping = RestoreMapping {
1368            members: vec![
1369                RestoreMappingEntry {
1370                    source_canister: ROOT.to_string(),
1371                    target_canister: ROOT.to_string(),
1372                },
1373                RestoreMappingEntry {
1374                    source_canister: CHILD.to_string(),
1375                    target_canister: TARGET.to_string(),
1376                },
1377            ],
1378        };
1379        let mapped = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
1380
1381        assert!(mapped.identity_summary.mapping_supplied);
1382        assert!(mapped.identity_summary.all_sources_mapped);
1383        assert_eq!(mapped.identity_summary.mapped_members, 2);
1384        assert_eq!(mapped.identity_summary.remapped_members, 1);
1385    }
1386
1387    // Ensure restore plans summarize snapshot provenance completeness.
1388    #[test]
1389    fn plan_includes_snapshot_summary() {
1390        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1391        manifest.fleet.members[1].source_snapshot.module_hash = None;
1392        manifest.fleet.members[1].source_snapshot.wasm_hash = None;
1393        manifest.fleet.members[1].source_snapshot.checksum = None;
1394
1395        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1396
1397        assert!(!plan.snapshot_summary.all_members_have_module_hash);
1398        assert!(!plan.snapshot_summary.all_members_have_wasm_hash);
1399        assert!(plan.snapshot_summary.all_members_have_code_version);
1400        assert!(!plan.snapshot_summary.all_members_have_checksum);
1401        assert_eq!(plan.snapshot_summary.members_with_module_hash, 1);
1402        assert_eq!(plan.snapshot_summary.members_with_wasm_hash, 1);
1403        assert_eq!(plan.snapshot_summary.members_with_code_version, 2);
1404        assert_eq!(plan.snapshot_summary.members_with_checksum, 1);
1405        assert!(!plan.readiness_summary.ready);
1406        assert_eq!(
1407            plan.readiness_summary.reasons,
1408            [
1409                "missing-module-hash",
1410                "missing-wasm-hash",
1411                "missing-snapshot-checksum"
1412            ]
1413        );
1414    }
1415
1416    // Ensure restore plans summarize manifest-level verification work.
1417    #[test]
1418    fn plan_includes_verification_summary() {
1419        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1420        manifest.verification.fleet_checks.push(VerificationCheck {
1421            kind: "fleet-ready".to_string(),
1422            method: None,
1423            roles: Vec::new(),
1424        });
1425        manifest
1426            .verification
1427            .member_checks
1428            .push(MemberVerificationChecks {
1429                role: "app".to_string(),
1430                checks: vec![VerificationCheck {
1431                    kind: "app-ready".to_string(),
1432                    method: Some("ready".to_string()),
1433                    roles: Vec::new(),
1434                }],
1435            });
1436
1437        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1438
1439        assert!(plan.verification_summary.verification_required);
1440        assert!(plan.verification_summary.all_members_have_checks);
1441        assert_eq!(plan.verification_summary.fleet_checks, 1);
1442        assert_eq!(plan.verification_summary.member_check_groups, 1);
1443        assert_eq!(plan.verification_summary.member_checks, 3);
1444        assert_eq!(plan.verification_summary.members_with_checks, 2);
1445        assert_eq!(plan.verification_summary.total_checks, 4);
1446    }
1447
1448    // Ensure restore plans summarize the concrete operation counts automation will schedule.
1449    #[test]
1450    fn plan_includes_operation_summary() {
1451        let manifest = valid_manifest(IdentityMode::Relocatable);
1452
1453        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1454
1455        assert_eq!(plan.operation_summary.planned_snapshot_loads, 2);
1456        assert_eq!(plan.operation_summary.planned_code_reinstalls, 2);
1457        assert_eq!(plan.operation_summary.planned_verification_checks, 2);
1458        assert_eq!(plan.operation_summary.planned_phases, 1);
1459    }
1460
1461    // Ensure initial restore status mirrors the no-mutation restore plan.
1462    #[test]
1463    fn restore_status_starts_all_members_as_planned() {
1464        let manifest = valid_manifest(IdentityMode::Relocatable);
1465
1466        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1467        let status = RestoreStatus::from_plan(&plan);
1468
1469        assert_eq!(status.status_version, 1);
1470        assert_eq!(status.backup_id.as_str(), plan.backup_id.as_str());
1471        assert_eq!(
1472            status.source_environment.as_str(),
1473            plan.source_environment.as_str()
1474        );
1475        assert_eq!(
1476            status.source_root_canister.as_str(),
1477            plan.source_root_canister.as_str()
1478        );
1479        assert_eq!(status.topology_hash.as_str(), plan.topology_hash.as_str());
1480        assert!(status.ready);
1481        assert!(status.readiness_reasons.is_empty());
1482        assert!(status.verification_required);
1483        assert_eq!(status.member_count, 2);
1484        assert_eq!(status.phase_count, 1);
1485        assert_eq!(status.planned_snapshot_loads, 2);
1486        assert_eq!(status.planned_code_reinstalls, 2);
1487        assert_eq!(status.planned_verification_checks, 2);
1488        assert_eq!(status.phases.len(), 1);
1489        assert_eq!(status.phases[0].restore_group, 1);
1490        assert_eq!(status.phases[0].members.len(), 2);
1491        assert_eq!(
1492            status.phases[0].members[0].state,
1493            RestoreMemberState::Planned
1494        );
1495        assert_eq!(status.phases[0].members[0].source_canister, ROOT);
1496        assert_eq!(status.phases[0].members[0].target_canister, ROOT);
1497        assert_eq!(status.phases[0].members[0].snapshot_id, "snap-root");
1498        assert_eq!(status.phases[0].members[0].artifact_path, "artifacts/root");
1499        assert_eq!(
1500            status.phases[0].members[1].state,
1501            RestoreMemberState::Planned
1502        );
1503        assert_eq!(status.phases[0].members[1].source_canister, CHILD);
1504    }
1505
1506    // Ensure apply dry-runs render ordered operations without mutating targets.
1507    #[test]
1508    fn apply_dry_run_renders_ordered_member_operations() {
1509        let manifest = valid_manifest(IdentityMode::Relocatable);
1510
1511        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1512        let status = RestoreStatus::from_plan(&plan);
1513        let dry_run =
1514            RestoreApplyDryRun::try_from_plan(&plan, Some(&status)).expect("dry-run should build");
1515
1516        assert_eq!(dry_run.dry_run_version, 1);
1517        assert_eq!(dry_run.backup_id.as_str(), "fbk_test_001");
1518        assert!(dry_run.ready);
1519        assert!(dry_run.status_supplied);
1520        assert_eq!(dry_run.member_count, 2);
1521        assert_eq!(dry_run.phase_count, 1);
1522        assert_eq!(dry_run.planned_snapshot_loads, 2);
1523        assert_eq!(dry_run.planned_code_reinstalls, 2);
1524        assert_eq!(dry_run.planned_verification_checks, 2);
1525        assert_eq!(dry_run.rendered_operations, 8);
1526        assert_eq!(dry_run.phases.len(), 1);
1527
1528        let operations = &dry_run.phases[0].operations;
1529        assert_eq!(operations[0].sequence, 0);
1530        assert_eq!(
1531            operations[0].operation,
1532            RestoreApplyOperationKind::UploadSnapshot
1533        );
1534        assert_eq!(operations[0].source_canister, ROOT);
1535        assert_eq!(operations[0].target_canister, ROOT);
1536        assert_eq!(operations[0].snapshot_id, Some("snap-root".to_string()));
1537        assert_eq!(
1538            operations[0].artifact_path,
1539            Some("artifacts/root".to_string())
1540        );
1541        assert_eq!(
1542            operations[1].operation,
1543            RestoreApplyOperationKind::LoadSnapshot
1544        );
1545        assert_eq!(
1546            operations[2].operation,
1547            RestoreApplyOperationKind::ReinstallCode
1548        );
1549        assert_eq!(
1550            operations[3].operation,
1551            RestoreApplyOperationKind::VerifyMember
1552        );
1553        assert_eq!(operations[3].verification_kind, Some("call".to_string()));
1554        assert_eq!(
1555            operations[3].verification_method,
1556            Some("canic_ready".to_string())
1557        );
1558        assert_eq!(operations[4].source_canister, CHILD);
1559        assert_eq!(
1560            operations[7].operation,
1561            RestoreApplyOperationKind::VerifyMember
1562        );
1563    }
1564
1565    // Ensure apply dry-run operation sequences remain unique across phases.
1566    #[test]
1567    fn apply_dry_run_sequences_operations_across_phases() {
1568        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1569        manifest.fleet.members[0].restore_group = 2;
1570
1571        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1572        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
1573
1574        assert_eq!(dry_run.phases.len(), 2);
1575        assert_eq!(dry_run.rendered_operations, 8);
1576        assert_eq!(dry_run.phases[0].operations[0].sequence, 0);
1577        assert_eq!(dry_run.phases[0].operations[3].sequence, 3);
1578        assert_eq!(dry_run.phases[1].operations[0].sequence, 4);
1579        assert_eq!(dry_run.phases[1].operations[3].sequence, 7);
1580    }
1581
1582    // Ensure apply dry-runs reject status files that do not match the plan.
1583    #[test]
1584    fn apply_dry_run_rejects_mismatched_status() {
1585        let manifest = valid_manifest(IdentityMode::Relocatable);
1586
1587        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1588        let mut status = RestoreStatus::from_plan(&plan);
1589        status.backup_id = "other-backup".to_string();
1590
1591        let err = RestoreApplyDryRun::try_from_plan(&plan, Some(&status))
1592            .expect_err("mismatched status should fail");
1593
1594        assert!(matches!(
1595            err,
1596            RestoreApplyDryRunError::StatusPlanMismatch {
1597                field: "backup_id",
1598                ..
1599            }
1600        ));
1601    }
1602
1603    // Ensure role-level verification checks are counted once per matching member.
1604    #[test]
1605    fn plan_expands_role_verification_checks_per_matching_member() {
1606        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1607        manifest.fleet.members.push(fleet_member(
1608            "app",
1609            CHILD_TWO,
1610            Some(ROOT),
1611            IdentityMode::Relocatable,
1612            1,
1613        ));
1614        manifest
1615            .verification
1616            .member_checks
1617            .push(MemberVerificationChecks {
1618                role: "app".to_string(),
1619                checks: vec![VerificationCheck {
1620                    kind: "app-ready".to_string(),
1621                    method: Some("ready".to_string()),
1622                    roles: Vec::new(),
1623                }],
1624            });
1625
1626        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1627
1628        assert_eq!(plan.verification_summary.fleet_checks, 0);
1629        assert_eq!(plan.verification_summary.member_check_groups, 1);
1630        assert_eq!(plan.verification_summary.member_checks, 5);
1631        assert_eq!(plan.verification_summary.members_with_checks, 3);
1632        assert_eq!(plan.verification_summary.total_checks, 5);
1633    }
1634
1635    // Ensure mapped restores must cover every source member.
1636    #[test]
1637    fn mapped_restore_requires_complete_mapping() {
1638        let manifest = valid_manifest(IdentityMode::Relocatable);
1639        let mapping = RestoreMapping {
1640            members: vec![RestoreMappingEntry {
1641                source_canister: ROOT.to_string(),
1642                target_canister: ROOT.to_string(),
1643            }],
1644        };
1645
1646        let err = RestorePlanner::plan(&manifest, Some(&mapping))
1647            .expect_err("incomplete mapping should fail");
1648
1649        assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
1650    }
1651
1652    // Ensure mappings cannot silently include canisters outside the manifest.
1653    #[test]
1654    fn mapped_restore_rejects_unknown_mapping_sources() {
1655        let manifest = valid_manifest(IdentityMode::Relocatable);
1656        let unknown = "rdmx6-jaaaa-aaaaa-aaadq-cai";
1657        let mapping = RestoreMapping {
1658            members: vec![
1659                RestoreMappingEntry {
1660                    source_canister: ROOT.to_string(),
1661                    target_canister: ROOT.to_string(),
1662                },
1663                RestoreMappingEntry {
1664                    source_canister: CHILD.to_string(),
1665                    target_canister: TARGET.to_string(),
1666                },
1667                RestoreMappingEntry {
1668                    source_canister: unknown.to_string(),
1669                    target_canister: unknown.to_string(),
1670                },
1671            ],
1672        };
1673
1674        let err = RestorePlanner::plan(&manifest, Some(&mapping))
1675            .expect_err("unknown mapping source should fail");
1676
1677        assert!(matches!(err, RestorePlanError::UnknownMappingSource(_)));
1678    }
1679
1680    // Ensure duplicate target mappings fail before a plan is produced.
1681    #[test]
1682    fn duplicate_mapping_targets_fail_validation() {
1683        let manifest = valid_manifest(IdentityMode::Relocatable);
1684        let mapping = RestoreMapping {
1685            members: vec![
1686                RestoreMappingEntry {
1687                    source_canister: ROOT.to_string(),
1688                    target_canister: ROOT.to_string(),
1689                },
1690                RestoreMappingEntry {
1691                    source_canister: CHILD.to_string(),
1692                    target_canister: ROOT.to_string(),
1693                },
1694            ],
1695        };
1696
1697        let err = RestorePlanner::plan(&manifest, Some(&mapping))
1698            .expect_err("duplicate targets should fail");
1699
1700        assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
1701    }
1702}