Skip to main content

canic_backup/restore/
mod.rs

1use crate::{
2    artifacts::{ArtifactChecksum, ArtifactChecksumError},
3    manifest::{
4        FleetBackupManifest, FleetMember, IdentityMode, ManifestValidationError, SourceSnapshot,
5        VerificationCheck,
6    },
7};
8use candid::Principal;
9use serde::{Deserialize, Serialize};
10use std::{
11    collections::{BTreeMap, BTreeSet},
12    path::{Component, Path, PathBuf},
13    str::FromStr,
14};
15use thiserror::Error as ThisError;
16
17///
18/// RestoreMapping
19///
20
21#[derive(Clone, Debug, Default, Deserialize, Serialize)]
22pub struct RestoreMapping {
23    pub members: Vec<RestoreMappingEntry>,
24}
25
26impl RestoreMapping {
27    /// Resolve the target canister for one source member.
28    fn target_for(&self, source_canister: &str) -> Option<&str> {
29        self.members
30            .iter()
31            .find(|entry| entry.source_canister == source_canister)
32            .map(|entry| entry.target_canister.as_str())
33    }
34}
35
36///
37/// RestoreMappingEntry
38///
39
40#[derive(Clone, Debug, Deserialize, Serialize)]
41pub struct RestoreMappingEntry {
42    pub source_canister: String,
43    pub target_canister: String,
44}
45
46///
47/// RestorePlan
48///
49
50#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
51pub struct RestorePlan {
52    pub backup_id: String,
53    pub source_environment: String,
54    pub source_root_canister: String,
55    pub topology_hash: String,
56    pub member_count: usize,
57    pub identity_summary: RestoreIdentitySummary,
58    pub snapshot_summary: RestoreSnapshotSummary,
59    pub verification_summary: RestoreVerificationSummary,
60    pub readiness_summary: RestoreReadinessSummary,
61    pub operation_summary: RestoreOperationSummary,
62    pub ordering_summary: RestoreOrderingSummary,
63    pub phases: Vec<RestorePhase>,
64}
65
66impl RestorePlan {
67    /// Return all planned members in execution order.
68    #[must_use]
69    pub fn ordered_members(&self) -> Vec<&RestorePlanMember> {
70        self.phases
71            .iter()
72            .flat_map(|phase| phase.members.iter())
73            .collect()
74    }
75}
76
77///
78/// RestoreStatus
79///
80
81#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
82pub struct RestoreStatus {
83    pub status_version: u16,
84    pub backup_id: String,
85    pub source_environment: String,
86    pub source_root_canister: String,
87    pub topology_hash: String,
88    pub ready: bool,
89    pub readiness_reasons: Vec<String>,
90    pub verification_required: bool,
91    pub member_count: usize,
92    pub phase_count: usize,
93    pub planned_snapshot_loads: usize,
94    pub planned_code_reinstalls: usize,
95    pub planned_verification_checks: usize,
96    pub phases: Vec<RestoreStatusPhase>,
97}
98
99impl RestoreStatus {
100    /// Build the initial no-mutation restore status from a computed plan.
101    #[must_use]
102    pub fn from_plan(plan: &RestorePlan) -> Self {
103        Self {
104            status_version: 1,
105            backup_id: plan.backup_id.clone(),
106            source_environment: plan.source_environment.clone(),
107            source_root_canister: plan.source_root_canister.clone(),
108            topology_hash: plan.topology_hash.clone(),
109            ready: plan.readiness_summary.ready,
110            readiness_reasons: plan.readiness_summary.reasons.clone(),
111            verification_required: plan.verification_summary.verification_required,
112            member_count: plan.member_count,
113            phase_count: plan.ordering_summary.phase_count,
114            planned_snapshot_loads: plan.operation_summary.planned_snapshot_loads,
115            planned_code_reinstalls: plan.operation_summary.planned_code_reinstalls,
116            planned_verification_checks: plan.operation_summary.planned_verification_checks,
117            phases: plan
118                .phases
119                .iter()
120                .map(RestoreStatusPhase::from_plan_phase)
121                .collect(),
122        }
123    }
124}
125
126///
127/// RestoreStatusPhase
128///
129
130#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
131pub struct RestoreStatusPhase {
132    pub restore_group: u16,
133    pub members: Vec<RestoreStatusMember>,
134}
135
136impl RestoreStatusPhase {
137    // Build one status phase from one planned restore phase.
138    fn from_plan_phase(phase: &RestorePhase) -> Self {
139        Self {
140            restore_group: phase.restore_group,
141            members: phase
142                .members
143                .iter()
144                .map(RestoreStatusMember::from_plan_member)
145                .collect(),
146        }
147    }
148}
149
150///
151/// RestoreStatusMember
152///
153
154#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
155pub struct RestoreStatusMember {
156    pub source_canister: String,
157    pub target_canister: String,
158    pub role: String,
159    pub restore_group: u16,
160    pub phase_order: usize,
161    pub snapshot_id: String,
162    pub artifact_path: String,
163    pub state: RestoreMemberState,
164}
165
166impl RestoreStatusMember {
167    // Build one member status row from one planned restore member.
168    fn from_plan_member(member: &RestorePlanMember) -> Self {
169        Self {
170            source_canister: member.source_canister.clone(),
171            target_canister: member.target_canister.clone(),
172            role: member.role.clone(),
173            restore_group: member.restore_group,
174            phase_order: member.phase_order,
175            snapshot_id: member.source_snapshot.snapshot_id.clone(),
176            artifact_path: member.source_snapshot.artifact_path.clone(),
177            state: RestoreMemberState::Planned,
178        }
179    }
180}
181
182///
183/// RestoreMemberState
184///
185
186#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
187#[serde(rename_all = "kebab-case")]
188pub enum RestoreMemberState {
189    Planned,
190}
191
192///
193/// RestoreApplyDryRun
194///
195
196#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
197pub struct RestoreApplyDryRun {
198    pub dry_run_version: u16,
199    pub backup_id: String,
200    pub ready: bool,
201    pub readiness_reasons: Vec<String>,
202    pub member_count: usize,
203    pub phase_count: usize,
204    pub status_supplied: bool,
205    pub planned_snapshot_loads: usize,
206    pub planned_code_reinstalls: usize,
207    pub planned_verification_checks: usize,
208    pub rendered_operations: usize,
209    pub artifact_validation: Option<RestoreApplyArtifactValidation>,
210    pub phases: Vec<RestoreApplyDryRunPhase>,
211}
212
213impl RestoreApplyDryRun {
214    /// Build a no-mutation apply dry-run after validating optional status identity.
215    pub fn try_from_plan(
216        plan: &RestorePlan,
217        status: Option<&RestoreStatus>,
218    ) -> Result<Self, RestoreApplyDryRunError> {
219        if let Some(status) = status {
220            validate_restore_status_matches_plan(plan, status)?;
221        }
222
223        Ok(Self::from_validated_plan(plan, status))
224    }
225
226    /// Build an apply dry-run and verify all referenced artifacts under a backup root.
227    pub fn try_from_plan_with_artifacts(
228        plan: &RestorePlan,
229        status: Option<&RestoreStatus>,
230        backup_root: &Path,
231    ) -> Result<Self, RestoreApplyDryRunError> {
232        let mut dry_run = Self::try_from_plan(plan, status)?;
233        dry_run.artifact_validation = Some(validate_restore_apply_artifacts(plan, backup_root)?);
234        Ok(dry_run)
235    }
236
237    // Build a no-mutation apply dry-run after any supplied status is validated.
238    fn from_validated_plan(plan: &RestorePlan, status: Option<&RestoreStatus>) -> Self {
239        let mut next_sequence = 0;
240        let phases = plan
241            .phases
242            .iter()
243            .map(|phase| RestoreApplyDryRunPhase::from_plan_phase(phase, &mut next_sequence))
244            .collect::<Vec<_>>();
245        let rendered_operations = phases
246            .iter()
247            .map(|phase| phase.operations.len())
248            .sum::<usize>();
249
250        Self {
251            dry_run_version: 1,
252            backup_id: plan.backup_id.clone(),
253            ready: status.map_or(plan.readiness_summary.ready, |status| status.ready),
254            readiness_reasons: status.map_or_else(
255                || plan.readiness_summary.reasons.clone(),
256                |status| status.readiness_reasons.clone(),
257            ),
258            member_count: plan.member_count,
259            phase_count: plan.ordering_summary.phase_count,
260            status_supplied: status.is_some(),
261            planned_snapshot_loads: plan.operation_summary.planned_snapshot_loads,
262            planned_code_reinstalls: plan.operation_summary.planned_code_reinstalls,
263            planned_verification_checks: plan.operation_summary.planned_verification_checks,
264            rendered_operations,
265            artifact_validation: None,
266            phases,
267        }
268    }
269}
270
271// Verify every planned restore artifact against one backup directory root.
272fn validate_restore_apply_artifacts(
273    plan: &RestorePlan,
274    backup_root: &Path,
275) -> Result<RestoreApplyArtifactValidation, RestoreApplyDryRunError> {
276    let mut checks = Vec::new();
277
278    for member in plan.ordered_members() {
279        checks.push(validate_restore_apply_artifact(member, backup_root)?);
280    }
281
282    let members_with_expected_checksums = checks
283        .iter()
284        .filter(|check| check.checksum_expected.is_some())
285        .count();
286    let artifacts_present = checks.iter().all(|check| check.exists);
287    let checksums_verified = members_with_expected_checksums == plan.member_count
288        && checks.iter().all(|check| check.checksum_verified);
289
290    Ok(RestoreApplyArtifactValidation {
291        backup_root: backup_root.to_string_lossy().to_string(),
292        checked_members: checks.len(),
293        artifacts_present,
294        checksums_verified,
295        members_with_expected_checksums,
296        checks,
297    })
298}
299
300// Verify one planned restore artifact path and checksum.
301fn validate_restore_apply_artifact(
302    member: &RestorePlanMember,
303    backup_root: &Path,
304) -> Result<RestoreApplyArtifactCheck, RestoreApplyDryRunError> {
305    let artifact_path = safe_restore_artifact_path(
306        &member.source_canister,
307        &member.source_snapshot.artifact_path,
308    )?;
309    let resolved_path = backup_root.join(&artifact_path);
310
311    if !resolved_path.exists() {
312        return Err(RestoreApplyDryRunError::ArtifactMissing {
313            source_canister: member.source_canister.clone(),
314            artifact_path: member.source_snapshot.artifact_path.clone(),
315            resolved_path: resolved_path.to_string_lossy().to_string(),
316        });
317    }
318
319    let (checksum_actual, checksum_verified) =
320        if let Some(expected) = &member.source_snapshot.checksum {
321            let checksum = ArtifactChecksum::from_path(&resolved_path).map_err(|source| {
322                RestoreApplyDryRunError::ArtifactChecksum {
323                    source_canister: member.source_canister.clone(),
324                    artifact_path: member.source_snapshot.artifact_path.clone(),
325                    source,
326                }
327            })?;
328            checksum.verify(expected).map_err(|source| {
329                RestoreApplyDryRunError::ArtifactChecksum {
330                    source_canister: member.source_canister.clone(),
331                    artifact_path: member.source_snapshot.artifact_path.clone(),
332                    source,
333                }
334            })?;
335            (Some(checksum.hash), true)
336        } else {
337            (None, false)
338        };
339
340    Ok(RestoreApplyArtifactCheck {
341        source_canister: member.source_canister.clone(),
342        target_canister: member.target_canister.clone(),
343        snapshot_id: member.source_snapshot.snapshot_id.clone(),
344        artifact_path: member.source_snapshot.artifact_path.clone(),
345        resolved_path: resolved_path.to_string_lossy().to_string(),
346        exists: true,
347        checksum_algorithm: member.source_snapshot.checksum_algorithm.clone(),
348        checksum_expected: member.source_snapshot.checksum.clone(),
349        checksum_actual,
350        checksum_verified,
351    })
352}
353
354// Reject absolute paths and parent traversal before joining with the backup root.
355fn safe_restore_artifact_path(
356    source_canister: &str,
357    artifact_path: &str,
358) -> Result<PathBuf, RestoreApplyDryRunError> {
359    let path = Path::new(artifact_path);
360    let is_safe = path
361        .components()
362        .all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
363
364    if is_safe {
365        return Ok(path.to_path_buf());
366    }
367
368    Err(RestoreApplyDryRunError::ArtifactPathEscapesBackup {
369        source_canister: source_canister.to_string(),
370        artifact_path: artifact_path.to_string(),
371    })
372}
373
374// Validate that a supplied restore status belongs to the restore plan.
375fn validate_restore_status_matches_plan(
376    plan: &RestorePlan,
377    status: &RestoreStatus,
378) -> Result<(), RestoreApplyDryRunError> {
379    validate_status_string_field("backup_id", &plan.backup_id, &status.backup_id)?;
380    validate_status_string_field(
381        "source_environment",
382        &plan.source_environment,
383        &status.source_environment,
384    )?;
385    validate_status_string_field(
386        "source_root_canister",
387        &plan.source_root_canister,
388        &status.source_root_canister,
389    )?;
390    validate_status_string_field("topology_hash", &plan.topology_hash, &status.topology_hash)?;
391    validate_status_usize_field("member_count", plan.member_count, status.member_count)?;
392    validate_status_usize_field(
393        "phase_count",
394        plan.ordering_summary.phase_count,
395        status.phase_count,
396    )?;
397    Ok(())
398}
399
400// Validate one string field shared by restore plan and status.
401fn validate_status_string_field(
402    field: &'static str,
403    plan: &str,
404    status: &str,
405) -> Result<(), RestoreApplyDryRunError> {
406    if plan == status {
407        return Ok(());
408    }
409
410    Err(RestoreApplyDryRunError::StatusPlanMismatch {
411        field,
412        plan: plan.to_string(),
413        status: status.to_string(),
414    })
415}
416
417// Validate one numeric field shared by restore plan and status.
418const fn validate_status_usize_field(
419    field: &'static str,
420    plan: usize,
421    status: usize,
422) -> Result<(), RestoreApplyDryRunError> {
423    if plan == status {
424        return Ok(());
425    }
426
427    Err(RestoreApplyDryRunError::StatusPlanCountMismatch {
428        field,
429        plan,
430        status,
431    })
432}
433
434///
435/// RestoreApplyArtifactValidation
436///
437
438#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
439pub struct RestoreApplyArtifactValidation {
440    pub backup_root: String,
441    pub checked_members: usize,
442    pub artifacts_present: bool,
443    pub checksums_verified: bool,
444    pub members_with_expected_checksums: usize,
445    pub checks: Vec<RestoreApplyArtifactCheck>,
446}
447
448///
449/// RestoreApplyArtifactCheck
450///
451
452#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
453pub struct RestoreApplyArtifactCheck {
454    pub source_canister: String,
455    pub target_canister: String,
456    pub snapshot_id: String,
457    pub artifact_path: String,
458    pub resolved_path: String,
459    pub exists: bool,
460    pub checksum_algorithm: String,
461    pub checksum_expected: Option<String>,
462    pub checksum_actual: Option<String>,
463    pub checksum_verified: bool,
464}
465
466///
467/// RestoreApplyDryRunPhase
468///
469
470#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
471pub struct RestoreApplyDryRunPhase {
472    pub restore_group: u16,
473    pub operations: Vec<RestoreApplyDryRunOperation>,
474}
475
476impl RestoreApplyDryRunPhase {
477    // Build one dry-run phase from one restore plan phase.
478    fn from_plan_phase(phase: &RestorePhase, next_sequence: &mut usize) -> Self {
479        let mut operations = Vec::new();
480
481        for member in &phase.members {
482            push_member_operation(
483                &mut operations,
484                next_sequence,
485                RestoreApplyOperationKind::UploadSnapshot,
486                member,
487                None,
488            );
489            push_member_operation(
490                &mut operations,
491                next_sequence,
492                RestoreApplyOperationKind::LoadSnapshot,
493                member,
494                None,
495            );
496            push_member_operation(
497                &mut operations,
498                next_sequence,
499                RestoreApplyOperationKind::ReinstallCode,
500                member,
501                None,
502            );
503
504            for check in &member.verification_checks {
505                push_member_operation(
506                    &mut operations,
507                    next_sequence,
508                    RestoreApplyOperationKind::VerifyMember,
509                    member,
510                    Some(check),
511                );
512            }
513        }
514
515        Self {
516            restore_group: phase.restore_group,
517            operations,
518        }
519    }
520}
521
522// Append one member-level dry-run operation using the current phase order.
523fn push_member_operation(
524    operations: &mut Vec<RestoreApplyDryRunOperation>,
525    next_sequence: &mut usize,
526    operation: RestoreApplyOperationKind,
527    member: &RestorePlanMember,
528    check: Option<&VerificationCheck>,
529) {
530    let sequence = *next_sequence;
531    *next_sequence += 1;
532
533    operations.push(RestoreApplyDryRunOperation {
534        sequence,
535        operation,
536        restore_group: member.restore_group,
537        phase_order: member.phase_order,
538        source_canister: member.source_canister.clone(),
539        target_canister: member.target_canister.clone(),
540        role: member.role.clone(),
541        snapshot_id: Some(member.source_snapshot.snapshot_id.clone()),
542        artifact_path: Some(member.source_snapshot.artifact_path.clone()),
543        verification_kind: check.map(|check| check.kind.clone()),
544        verification_method: check.and_then(|check| check.method.clone()),
545    });
546}
547
548///
549/// RestoreApplyDryRunOperation
550///
551
552#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
553pub struct RestoreApplyDryRunOperation {
554    pub sequence: usize,
555    pub operation: RestoreApplyOperationKind,
556    pub restore_group: u16,
557    pub phase_order: usize,
558    pub source_canister: String,
559    pub target_canister: String,
560    pub role: String,
561    pub snapshot_id: Option<String>,
562    pub artifact_path: Option<String>,
563    pub verification_kind: Option<String>,
564    pub verification_method: Option<String>,
565}
566
567///
568/// RestoreApplyOperationKind
569///
570
571#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
572#[serde(rename_all = "kebab-case")]
573pub enum RestoreApplyOperationKind {
574    UploadSnapshot,
575    LoadSnapshot,
576    ReinstallCode,
577    VerifyMember,
578}
579
580///
581/// RestoreApplyDryRunError
582///
583
584#[derive(Debug, ThisError)]
585pub enum RestoreApplyDryRunError {
586    #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
587    StatusPlanMismatch {
588        field: &'static str,
589        plan: String,
590        status: String,
591    },
592
593    #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
594    StatusPlanCountMismatch {
595        field: &'static str,
596        plan: usize,
597        status: usize,
598    },
599
600    #[error("restore artifact path for {source_canister} escapes backup root: {artifact_path}")]
601    ArtifactPathEscapesBackup {
602        source_canister: String,
603        artifact_path: String,
604    },
605
606    #[error(
607        "restore artifact for {source_canister} is missing: {artifact_path} at {resolved_path}"
608    )]
609    ArtifactMissing {
610        source_canister: String,
611        artifact_path: String,
612        resolved_path: String,
613    },
614
615    #[error("restore artifact checksum failed for {source_canister} at {artifact_path}: {source}")]
616    ArtifactChecksum {
617        source_canister: String,
618        artifact_path: String,
619        #[source]
620        source: ArtifactChecksumError,
621    },
622}
623
624///
625/// RestoreIdentitySummary
626///
627
628#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
629pub struct RestoreIdentitySummary {
630    pub mapping_supplied: bool,
631    pub all_sources_mapped: bool,
632    pub fixed_members: usize,
633    pub relocatable_members: usize,
634    pub in_place_members: usize,
635    pub mapped_members: usize,
636    pub remapped_members: usize,
637}
638
639///
640/// RestoreSnapshotSummary
641///
642
643#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
644#[expect(
645    clippy::struct_excessive_bools,
646    reason = "restore summaries intentionally expose machine-readable readiness flags"
647)]
648pub struct RestoreSnapshotSummary {
649    pub all_members_have_module_hash: bool,
650    pub all_members_have_wasm_hash: bool,
651    pub all_members_have_code_version: bool,
652    pub all_members_have_checksum: bool,
653    pub members_with_module_hash: usize,
654    pub members_with_wasm_hash: usize,
655    pub members_with_code_version: usize,
656    pub members_with_checksum: usize,
657}
658
659///
660/// RestoreVerificationSummary
661///
662
663#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
664pub struct RestoreVerificationSummary {
665    pub verification_required: bool,
666    pub all_members_have_checks: bool,
667    pub fleet_checks: usize,
668    pub member_check_groups: usize,
669    pub member_checks: usize,
670    pub members_with_checks: usize,
671    pub total_checks: usize,
672}
673
674///
675/// RestoreReadinessSummary
676///
677
678#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
679pub struct RestoreReadinessSummary {
680    pub ready: bool,
681    pub reasons: Vec<String>,
682}
683
684///
685/// RestoreOperationSummary
686///
687
688#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
689pub struct RestoreOperationSummary {
690    pub planned_snapshot_loads: usize,
691    pub planned_code_reinstalls: usize,
692    pub planned_verification_checks: usize,
693    pub planned_phases: usize,
694}
695
696///
697/// RestoreOrderingSummary
698///
699
700#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
701pub struct RestoreOrderingSummary {
702    pub phase_count: usize,
703    pub dependency_free_members: usize,
704    pub in_group_parent_edges: usize,
705    pub cross_group_parent_edges: usize,
706}
707
708///
709/// RestorePhase
710///
711
712#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
713pub struct RestorePhase {
714    pub restore_group: u16,
715    pub members: Vec<RestorePlanMember>,
716}
717
718///
719/// RestorePlanMember
720///
721
722#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
723pub struct RestorePlanMember {
724    pub source_canister: String,
725    pub target_canister: String,
726    pub role: String,
727    pub parent_source_canister: Option<String>,
728    pub parent_target_canister: Option<String>,
729    pub ordering_dependency: Option<RestoreOrderingDependency>,
730    pub phase_order: usize,
731    pub restore_group: u16,
732    pub identity_mode: IdentityMode,
733    pub verification_class: String,
734    pub verification_checks: Vec<VerificationCheck>,
735    pub source_snapshot: SourceSnapshot,
736}
737
738///
739/// RestoreOrderingDependency
740///
741
742#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
743pub struct RestoreOrderingDependency {
744    pub source_canister: String,
745    pub target_canister: String,
746    pub relationship: RestoreOrderingRelationship,
747}
748
749///
750/// RestoreOrderingRelationship
751///
752
753#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
754#[serde(rename_all = "kebab-case")]
755pub enum RestoreOrderingRelationship {
756    ParentInSameGroup,
757    ParentInEarlierGroup,
758}
759
760///
761/// RestorePlanner
762///
763
764pub struct RestorePlanner;
765
766impl RestorePlanner {
767    /// Build a no-mutation restore plan from the manifest and optional target mapping.
768    pub fn plan(
769        manifest: &FleetBackupManifest,
770        mapping: Option<&RestoreMapping>,
771    ) -> Result<RestorePlan, RestorePlanError> {
772        manifest.validate()?;
773        if let Some(mapping) = mapping {
774            validate_mapping(mapping)?;
775            validate_mapping_sources(manifest, mapping)?;
776        }
777
778        let members = resolve_members(manifest, mapping)?;
779        let identity_summary = restore_identity_summary(&members, mapping.is_some());
780        let snapshot_summary = restore_snapshot_summary(&members);
781        let verification_summary = restore_verification_summary(manifest, &members);
782        let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
783        validate_restore_group_dependencies(&members)?;
784        let phases = group_and_order_members(members)?;
785        let ordering_summary = restore_ordering_summary(&phases);
786        let operation_summary =
787            restore_operation_summary(manifest.fleet.members.len(), &verification_summary, &phases);
788
789        Ok(RestorePlan {
790            backup_id: manifest.backup_id.clone(),
791            source_environment: manifest.source.environment.clone(),
792            source_root_canister: manifest.source.root_canister.clone(),
793            topology_hash: manifest.fleet.topology_hash.clone(),
794            member_count: manifest.fleet.members.len(),
795            identity_summary,
796            snapshot_summary,
797            verification_summary,
798            readiness_summary,
799            operation_summary,
800            ordering_summary,
801            phases,
802        })
803    }
804}
805
806///
807/// RestorePlanError
808///
809
810#[derive(Debug, ThisError)]
811pub enum RestorePlanError {
812    #[error(transparent)]
813    InvalidManifest(#[from] ManifestValidationError),
814
815    #[error("field {field} must be a valid principal: {value}")]
816    InvalidPrincipal { field: &'static str, value: String },
817
818    #[error("mapping contains duplicate source canister {0}")]
819    DuplicateMappingSource(String),
820
821    #[error("mapping contains duplicate target canister {0}")]
822    DuplicateMappingTarget(String),
823
824    #[error("mapping references unknown source canister {0}")]
825    UnknownMappingSource(String),
826
827    #[error("mapping is missing source canister {0}")]
828    MissingMappingSource(String),
829
830    #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
831    FixedIdentityRemap {
832        source_canister: String,
833        target_canister: String,
834    },
835
836    #[error("restore plan contains duplicate target canister {0}")]
837    DuplicatePlanTarget(String),
838
839    #[error("restore group {0} contains a parent cycle or unresolved dependency")]
840    RestoreOrderCycle(u16),
841
842    #[error(
843        "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
844    )]
845    ParentRestoreGroupAfterChild {
846        child_source_canister: String,
847        parent_source_canister: String,
848        child_restore_group: u16,
849        parent_restore_group: u16,
850    },
851}
852
853// Validate a user-supplied restore mapping before applying it to the manifest.
854fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
855    let mut sources = BTreeSet::new();
856    let mut targets = BTreeSet::new();
857
858    for entry in &mapping.members {
859        validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
860        validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
861
862        if !sources.insert(entry.source_canister.clone()) {
863            return Err(RestorePlanError::DuplicateMappingSource(
864                entry.source_canister.clone(),
865            ));
866        }
867
868        if !targets.insert(entry.target_canister.clone()) {
869            return Err(RestorePlanError::DuplicateMappingTarget(
870                entry.target_canister.clone(),
871            ));
872        }
873    }
874
875    Ok(())
876}
877
878// Ensure mappings only reference members declared in the manifest.
879fn validate_mapping_sources(
880    manifest: &FleetBackupManifest,
881    mapping: &RestoreMapping,
882) -> Result<(), RestorePlanError> {
883    let sources = manifest
884        .fleet
885        .members
886        .iter()
887        .map(|member| member.canister_id.as_str())
888        .collect::<BTreeSet<_>>();
889
890    for entry in &mapping.members {
891        if !sources.contains(entry.source_canister.as_str()) {
892            return Err(RestorePlanError::UnknownMappingSource(
893                entry.source_canister.clone(),
894            ));
895        }
896    }
897
898    Ok(())
899}
900
901// Resolve source manifest members into target restore members.
902fn resolve_members(
903    manifest: &FleetBackupManifest,
904    mapping: Option<&RestoreMapping>,
905) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
906    let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
907    let mut targets = BTreeSet::new();
908    let mut source_to_target = BTreeMap::new();
909
910    for member in &manifest.fleet.members {
911        let target = resolve_target(member, mapping)?;
912        if !targets.insert(target.clone()) {
913            return Err(RestorePlanError::DuplicatePlanTarget(target));
914        }
915
916        source_to_target.insert(member.canister_id.clone(), target.clone());
917        plan_members.push(RestorePlanMember {
918            source_canister: member.canister_id.clone(),
919            target_canister: target,
920            role: member.role.clone(),
921            parent_source_canister: member.parent_canister_id.clone(),
922            parent_target_canister: None,
923            ordering_dependency: None,
924            phase_order: 0,
925            restore_group: member.restore_group,
926            identity_mode: member.identity_mode.clone(),
927            verification_class: member.verification_class.clone(),
928            verification_checks: member.verification_checks.clone(),
929            source_snapshot: member.source_snapshot.clone(),
930        });
931    }
932
933    for member in &mut plan_members {
934        member.parent_target_canister = member
935            .parent_source_canister
936            .as_ref()
937            .and_then(|parent| source_to_target.get(parent))
938            .cloned();
939    }
940
941    Ok(plan_members)
942}
943
944// Resolve one member's target canister, enforcing identity continuity.
945fn resolve_target(
946    member: &FleetMember,
947    mapping: Option<&RestoreMapping>,
948) -> Result<String, RestorePlanError> {
949    let target = match mapping {
950        Some(mapping) => mapping
951            .target_for(&member.canister_id)
952            .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
953            .to_string(),
954        None => member.canister_id.clone(),
955    };
956
957    if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
958        return Err(RestorePlanError::FixedIdentityRemap {
959            source_canister: member.canister_id.clone(),
960            target_canister: target,
961        });
962    }
963
964    Ok(target)
965}
966
967// Summarize identity and mapping decisions before grouping restore phases.
968fn restore_identity_summary(
969    members: &[RestorePlanMember],
970    mapping_supplied: bool,
971) -> RestoreIdentitySummary {
972    let mut summary = RestoreIdentitySummary {
973        mapping_supplied,
974        all_sources_mapped: false,
975        fixed_members: 0,
976        relocatable_members: 0,
977        in_place_members: 0,
978        mapped_members: 0,
979        remapped_members: 0,
980    };
981
982    for member in members {
983        match member.identity_mode {
984            IdentityMode::Fixed => summary.fixed_members += 1,
985            IdentityMode::Relocatable => summary.relocatable_members += 1,
986        }
987
988        if member.source_canister == member.target_canister {
989            summary.in_place_members += 1;
990        } else {
991            summary.remapped_members += 1;
992        }
993        if mapping_supplied {
994            summary.mapped_members += 1;
995        }
996    }
997
998    summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
999
1000    summary
1001}
1002
1003// Summarize snapshot provenance completeness before grouping restore phases.
1004fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
1005    let members_with_module_hash = members
1006        .iter()
1007        .filter(|member| member.source_snapshot.module_hash.is_some())
1008        .count();
1009    let members_with_wasm_hash = members
1010        .iter()
1011        .filter(|member| member.source_snapshot.wasm_hash.is_some())
1012        .count();
1013    let members_with_code_version = members
1014        .iter()
1015        .filter(|member| member.source_snapshot.code_version.is_some())
1016        .count();
1017    let members_with_checksum = members
1018        .iter()
1019        .filter(|member| member.source_snapshot.checksum.is_some())
1020        .count();
1021
1022    RestoreSnapshotSummary {
1023        all_members_have_module_hash: members_with_module_hash == members.len(),
1024        all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
1025        all_members_have_code_version: members_with_code_version == members.len(),
1026        all_members_have_checksum: members_with_checksum == members.len(),
1027        members_with_module_hash,
1028        members_with_wasm_hash,
1029        members_with_code_version,
1030        members_with_checksum,
1031    }
1032}
1033
1034// Summarize whether restore planning has the metadata required for automation.
1035fn restore_readiness_summary(
1036    snapshot: &RestoreSnapshotSummary,
1037    verification: &RestoreVerificationSummary,
1038) -> RestoreReadinessSummary {
1039    let mut reasons = Vec::new();
1040
1041    if !snapshot.all_members_have_module_hash {
1042        reasons.push("missing-module-hash".to_string());
1043    }
1044    if !snapshot.all_members_have_wasm_hash {
1045        reasons.push("missing-wasm-hash".to_string());
1046    }
1047    if !snapshot.all_members_have_code_version {
1048        reasons.push("missing-code-version".to_string());
1049    }
1050    if !snapshot.all_members_have_checksum {
1051        reasons.push("missing-snapshot-checksum".to_string());
1052    }
1053    if !verification.all_members_have_checks {
1054        reasons.push("missing-verification-checks".to_string());
1055    }
1056
1057    RestoreReadinessSummary {
1058        ready: reasons.is_empty(),
1059        reasons,
1060    }
1061}
1062
1063// Summarize restore verification work declared by the manifest and members.
1064fn restore_verification_summary(
1065    manifest: &FleetBackupManifest,
1066    members: &[RestorePlanMember],
1067) -> RestoreVerificationSummary {
1068    let fleet_checks = manifest.verification.fleet_checks.len();
1069    let member_check_groups = manifest.verification.member_checks.len();
1070    let role_check_counts = manifest
1071        .verification
1072        .member_checks
1073        .iter()
1074        .map(|group| (group.role.as_str(), group.checks.len()))
1075        .collect::<BTreeMap<_, _>>();
1076    let inline_member_checks = members
1077        .iter()
1078        .map(|member| member.verification_checks.len())
1079        .sum::<usize>();
1080    let role_member_checks = members
1081        .iter()
1082        .map(|member| {
1083            role_check_counts
1084                .get(member.role.as_str())
1085                .copied()
1086                .unwrap_or(0)
1087        })
1088        .sum::<usize>();
1089    let member_checks = inline_member_checks + role_member_checks;
1090    let members_with_checks = members
1091        .iter()
1092        .filter(|member| {
1093            !member.verification_checks.is_empty()
1094                || role_check_counts.contains_key(member.role.as_str())
1095        })
1096        .count();
1097
1098    RestoreVerificationSummary {
1099        verification_required: true,
1100        all_members_have_checks: members_with_checks == members.len(),
1101        fleet_checks,
1102        member_check_groups,
1103        member_checks,
1104        members_with_checks,
1105        total_checks: fleet_checks + member_checks,
1106    }
1107}
1108
1109// Summarize the concrete restore operations implied by a no-mutation plan.
1110const fn restore_operation_summary(
1111    member_count: usize,
1112    verification_summary: &RestoreVerificationSummary,
1113    phases: &[RestorePhase],
1114) -> RestoreOperationSummary {
1115    RestoreOperationSummary {
1116        planned_snapshot_loads: member_count,
1117        planned_code_reinstalls: member_count,
1118        planned_verification_checks: verification_summary.total_checks,
1119        planned_phases: phases.len(),
1120    }
1121}
1122
1123// Reject group assignments that would restore a child before its parent.
1124fn validate_restore_group_dependencies(
1125    members: &[RestorePlanMember],
1126) -> Result<(), RestorePlanError> {
1127    let groups_by_source = members
1128        .iter()
1129        .map(|member| (member.source_canister.as_str(), member.restore_group))
1130        .collect::<BTreeMap<_, _>>();
1131
1132    for member in members {
1133        let Some(parent) = &member.parent_source_canister else {
1134            continue;
1135        };
1136        let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
1137            continue;
1138        };
1139
1140        if *parent_group > member.restore_group {
1141            return Err(RestorePlanError::ParentRestoreGroupAfterChild {
1142                child_source_canister: member.source_canister.clone(),
1143                parent_source_canister: parent.clone(),
1144                child_restore_group: member.restore_group,
1145                parent_restore_group: *parent_group,
1146            });
1147        }
1148    }
1149
1150    Ok(())
1151}
1152
1153// Group members and apply parent-before-child ordering inside each group.
1154fn group_and_order_members(
1155    members: Vec<RestorePlanMember>,
1156) -> Result<Vec<RestorePhase>, RestorePlanError> {
1157    let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
1158    for member in members {
1159        groups.entry(member.restore_group).or_default().push(member);
1160    }
1161
1162    groups
1163        .into_iter()
1164        .map(|(restore_group, members)| {
1165            let members = order_group(restore_group, members)?;
1166            Ok(RestorePhase {
1167                restore_group,
1168                members,
1169            })
1170        })
1171        .collect()
1172}
1173
1174// Topologically order one group using manifest parent relationships.
1175fn order_group(
1176    restore_group: u16,
1177    members: Vec<RestorePlanMember>,
1178) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
1179    let mut remaining = members;
1180    let group_sources = remaining
1181        .iter()
1182        .map(|member| member.source_canister.clone())
1183        .collect::<BTreeSet<_>>();
1184    let mut emitted = BTreeSet::new();
1185    let mut ordered = Vec::with_capacity(remaining.len());
1186
1187    while !remaining.is_empty() {
1188        let Some(index) = remaining
1189            .iter()
1190            .position(|member| parent_satisfied(member, &group_sources, &emitted))
1191        else {
1192            return Err(RestorePlanError::RestoreOrderCycle(restore_group));
1193        };
1194
1195        let mut member = remaining.remove(index);
1196        member.phase_order = ordered.len();
1197        member.ordering_dependency = ordering_dependency(&member, &group_sources);
1198        emitted.insert(member.source_canister.clone());
1199        ordered.push(member);
1200    }
1201
1202    Ok(ordered)
1203}
1204
1205// Describe the topology dependency that controlled a member's restore ordering.
1206fn ordering_dependency(
1207    member: &RestorePlanMember,
1208    group_sources: &BTreeSet<String>,
1209) -> Option<RestoreOrderingDependency> {
1210    let parent_source = member.parent_source_canister.as_ref()?;
1211    let parent_target = member.parent_target_canister.as_ref()?;
1212    let relationship = if group_sources.contains(parent_source) {
1213        RestoreOrderingRelationship::ParentInSameGroup
1214    } else {
1215        RestoreOrderingRelationship::ParentInEarlierGroup
1216    };
1217
1218    Some(RestoreOrderingDependency {
1219        source_canister: parent_source.clone(),
1220        target_canister: parent_target.clone(),
1221        relationship,
1222    })
1223}
1224
1225// Summarize the dependency ordering metadata exposed in the restore plan.
1226fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
1227    let mut summary = RestoreOrderingSummary {
1228        phase_count: phases.len(),
1229        dependency_free_members: 0,
1230        in_group_parent_edges: 0,
1231        cross_group_parent_edges: 0,
1232    };
1233
1234    for member in phases.iter().flat_map(|phase| phase.members.iter()) {
1235        match &member.ordering_dependency {
1236            Some(dependency)
1237                if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
1238            {
1239                summary.in_group_parent_edges += 1;
1240            }
1241            Some(dependency)
1242                if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
1243            {
1244                summary.cross_group_parent_edges += 1;
1245            }
1246            Some(_) => {}
1247            None => summary.dependency_free_members += 1,
1248        }
1249    }
1250
1251    summary
1252}
1253
1254// Determine whether a member's in-group parent has already been emitted.
1255fn parent_satisfied(
1256    member: &RestorePlanMember,
1257    group_sources: &BTreeSet<String>,
1258    emitted: &BTreeSet<String>,
1259) -> bool {
1260    match &member.parent_source_canister {
1261        Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
1262        _ => true,
1263    }
1264}
1265
1266// Validate textual principal fields used in mappings.
1267fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
1268    Principal::from_str(value)
1269        .map(|_| ())
1270        .map_err(|_| RestorePlanError::InvalidPrincipal {
1271            field,
1272            value: value.to_string(),
1273        })
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278    use super::*;
1279    use crate::manifest::{
1280        BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetSection,
1281        MemberVerificationChecks, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
1282        VerificationPlan,
1283    };
1284    use std::{
1285        env, fs,
1286        path::{Path, PathBuf},
1287        time::{SystemTime, UNIX_EPOCH},
1288    };
1289
1290    const ROOT: &str = "aaaaa-aa";
1291    const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
1292    const CHILD_TWO: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";
1293    const TARGET: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
1294    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1295
1296    // Build one valid manifest with a parent and child in the same restore group.
1297    fn valid_manifest(identity_mode: IdentityMode) -> FleetBackupManifest {
1298        FleetBackupManifest {
1299            manifest_version: 1,
1300            backup_id: "fbk_test_001".to_string(),
1301            created_at: "2026-04-10T12:00:00Z".to_string(),
1302            tool: ToolMetadata {
1303                name: "canic".to_string(),
1304                version: "v1".to_string(),
1305            },
1306            source: SourceMetadata {
1307                environment: "local".to_string(),
1308                root_canister: ROOT.to_string(),
1309            },
1310            consistency: ConsistencySection {
1311                mode: ConsistencyMode::CrashConsistent,
1312                backup_units: vec![BackupUnit {
1313                    unit_id: "whole-fleet".to_string(),
1314                    kind: BackupUnitKind::WholeFleet,
1315                    roles: vec!["root".to_string(), "app".to_string()],
1316                    consistency_reason: None,
1317                    dependency_closure: Vec::new(),
1318                    topology_validation: "subtree-closed".to_string(),
1319                    quiescence_strategy: None,
1320                }],
1321            },
1322            fleet: FleetSection {
1323                topology_hash_algorithm: "sha256".to_string(),
1324                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1325                discovery_topology_hash: HASH.to_string(),
1326                pre_snapshot_topology_hash: HASH.to_string(),
1327                topology_hash: HASH.to_string(),
1328                members: vec![
1329                    fleet_member("app", CHILD, Some(ROOT), identity_mode, 1),
1330                    fleet_member("root", ROOT, None, IdentityMode::Fixed, 1),
1331                ],
1332            },
1333            verification: VerificationPlan {
1334                fleet_checks: Vec::new(),
1335                member_checks: Vec::new(),
1336            },
1337        }
1338    }
1339
1340    // Build one manifest member for restore planning tests.
1341    fn fleet_member(
1342        role: &str,
1343        canister_id: &str,
1344        parent_canister_id: Option<&str>,
1345        identity_mode: IdentityMode,
1346        restore_group: u16,
1347    ) -> FleetMember {
1348        FleetMember {
1349            role: role.to_string(),
1350            canister_id: canister_id.to_string(),
1351            parent_canister_id: parent_canister_id.map(str::to_string),
1352            subnet_canister_id: None,
1353            controller_hint: Some(ROOT.to_string()),
1354            identity_mode,
1355            restore_group,
1356            verification_class: "basic".to_string(),
1357            verification_checks: vec![VerificationCheck {
1358                kind: "call".to_string(),
1359                method: Some("canic_ready".to_string()),
1360                roles: Vec::new(),
1361            }],
1362            source_snapshot: SourceSnapshot {
1363                snapshot_id: format!("snap-{role}"),
1364                module_hash: Some(HASH.to_string()),
1365                wasm_hash: Some(HASH.to_string()),
1366                code_version: Some("v0.30.0".to_string()),
1367                artifact_path: format!("artifacts/{role}"),
1368                checksum_algorithm: "sha256".to_string(),
1369                checksum: Some(HASH.to_string()),
1370            },
1371        }
1372    }
1373
1374    // Ensure in-place restore planning sorts parent before child.
1375    #[test]
1376    fn in_place_plan_orders_parent_before_child() {
1377        let manifest = valid_manifest(IdentityMode::Relocatable);
1378
1379        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1380        let ordered = plan.ordered_members();
1381
1382        assert_eq!(plan.backup_id, "fbk_test_001");
1383        assert_eq!(plan.source_environment, "local");
1384        assert_eq!(plan.source_root_canister, ROOT);
1385        assert_eq!(plan.topology_hash, HASH);
1386        assert_eq!(plan.member_count, 2);
1387        assert_eq!(plan.identity_summary.fixed_members, 1);
1388        assert_eq!(plan.identity_summary.relocatable_members, 1);
1389        assert_eq!(plan.identity_summary.in_place_members, 2);
1390        assert_eq!(plan.identity_summary.mapped_members, 0);
1391        assert_eq!(plan.identity_summary.remapped_members, 0);
1392        assert!(plan.verification_summary.verification_required);
1393        assert!(plan.verification_summary.all_members_have_checks);
1394        assert!(plan.readiness_summary.ready);
1395        assert!(plan.readiness_summary.reasons.is_empty());
1396        assert_eq!(plan.verification_summary.fleet_checks, 0);
1397        assert_eq!(plan.verification_summary.member_check_groups, 0);
1398        assert_eq!(plan.verification_summary.member_checks, 2);
1399        assert_eq!(plan.verification_summary.members_with_checks, 2);
1400        assert_eq!(plan.verification_summary.total_checks, 2);
1401        assert_eq!(plan.ordering_summary.phase_count, 1);
1402        assert_eq!(plan.ordering_summary.dependency_free_members, 1);
1403        assert_eq!(plan.ordering_summary.in_group_parent_edges, 1);
1404        assert_eq!(plan.ordering_summary.cross_group_parent_edges, 0);
1405        assert_eq!(ordered[0].phase_order, 0);
1406        assert_eq!(ordered[1].phase_order, 1);
1407        assert_eq!(ordered[0].source_canister, ROOT);
1408        assert_eq!(ordered[1].source_canister, CHILD);
1409        assert_eq!(
1410            ordered[1].ordering_dependency,
1411            Some(RestoreOrderingDependency {
1412                source_canister: ROOT.to_string(),
1413                target_canister: ROOT.to_string(),
1414                relationship: RestoreOrderingRelationship::ParentInSameGroup,
1415            })
1416        );
1417    }
1418
1419    // Ensure cross-group parent dependencies are exposed when the parent phase is earlier.
1420    #[test]
1421    fn plan_reports_parent_dependency_from_earlier_group() {
1422        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1423        manifest.fleet.members[0].restore_group = 2;
1424        manifest.fleet.members[1].restore_group = 1;
1425
1426        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1427        let ordered = plan.ordered_members();
1428
1429        assert_eq!(plan.phases.len(), 2);
1430        assert_eq!(plan.ordering_summary.phase_count, 2);
1431        assert_eq!(plan.ordering_summary.dependency_free_members, 1);
1432        assert_eq!(plan.ordering_summary.in_group_parent_edges, 0);
1433        assert_eq!(plan.ordering_summary.cross_group_parent_edges, 1);
1434        assert_eq!(ordered[0].source_canister, ROOT);
1435        assert_eq!(ordered[1].source_canister, CHILD);
1436        assert_eq!(
1437            ordered[1].ordering_dependency,
1438            Some(RestoreOrderingDependency {
1439                source_canister: ROOT.to_string(),
1440                target_canister: ROOT.to_string(),
1441                relationship: RestoreOrderingRelationship::ParentInEarlierGroup,
1442            })
1443        );
1444    }
1445
1446    // Ensure restore planning fails when groups would restore a child before its parent.
1447    #[test]
1448    fn plan_rejects_parent_in_later_restore_group() {
1449        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1450        manifest.fleet.members[0].restore_group = 1;
1451        manifest.fleet.members[1].restore_group = 2;
1452
1453        let err = RestorePlanner::plan(&manifest, None)
1454            .expect_err("parent-after-child group ordering should fail");
1455
1456        assert!(matches!(
1457            err,
1458            RestorePlanError::ParentRestoreGroupAfterChild { .. }
1459        ));
1460    }
1461
1462    // Ensure fixed identities cannot be remapped.
1463    #[test]
1464    fn fixed_identity_member_cannot_be_remapped() {
1465        let manifest = valid_manifest(IdentityMode::Fixed);
1466        let mapping = RestoreMapping {
1467            members: vec![
1468                RestoreMappingEntry {
1469                    source_canister: ROOT.to_string(),
1470                    target_canister: ROOT.to_string(),
1471                },
1472                RestoreMappingEntry {
1473                    source_canister: CHILD.to_string(),
1474                    target_canister: TARGET.to_string(),
1475                },
1476            ],
1477        };
1478
1479        let err = RestorePlanner::plan(&manifest, Some(&mapping))
1480            .expect_err("fixed member remap should fail");
1481
1482        assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
1483    }
1484
1485    // Ensure relocatable identities may be mapped when all members are covered.
1486    #[test]
1487    fn relocatable_member_can_be_mapped() {
1488        let manifest = valid_manifest(IdentityMode::Relocatable);
1489        let mapping = RestoreMapping {
1490            members: vec![
1491                RestoreMappingEntry {
1492                    source_canister: ROOT.to_string(),
1493                    target_canister: ROOT.to_string(),
1494                },
1495                RestoreMappingEntry {
1496                    source_canister: CHILD.to_string(),
1497                    target_canister: TARGET.to_string(),
1498                },
1499            ],
1500        };
1501
1502        let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
1503        let child = plan
1504            .ordered_members()
1505            .into_iter()
1506            .find(|member| member.source_canister == CHILD)
1507            .expect("child member should be planned");
1508
1509        assert_eq!(plan.identity_summary.fixed_members, 1);
1510        assert_eq!(plan.identity_summary.relocatable_members, 1);
1511        assert_eq!(plan.identity_summary.in_place_members, 1);
1512        assert_eq!(plan.identity_summary.mapped_members, 2);
1513        assert_eq!(plan.identity_summary.remapped_members, 1);
1514        assert_eq!(child.target_canister, TARGET);
1515        assert_eq!(child.parent_target_canister, Some(ROOT.to_string()));
1516    }
1517
1518    // Ensure restore plans carry enough metadata for operator preflight.
1519    #[test]
1520    fn plan_members_include_snapshot_and_verification_metadata() {
1521        let manifest = valid_manifest(IdentityMode::Relocatable);
1522
1523        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1524        let root = plan
1525            .ordered_members()
1526            .into_iter()
1527            .find(|member| member.source_canister == ROOT)
1528            .expect("root member should be planned");
1529
1530        assert_eq!(root.identity_mode, IdentityMode::Fixed);
1531        assert_eq!(root.verification_class, "basic");
1532        assert_eq!(root.verification_checks[0].kind, "call");
1533        assert_eq!(root.source_snapshot.snapshot_id, "snap-root");
1534        assert_eq!(root.source_snapshot.artifact_path, "artifacts/root");
1535    }
1536
1537    // Ensure restore plans make mapping mode explicit.
1538    #[test]
1539    fn plan_includes_mapping_summary() {
1540        let manifest = valid_manifest(IdentityMode::Relocatable);
1541        let in_place = RestorePlanner::plan(&manifest, None).expect("plan should build");
1542
1543        assert!(!in_place.identity_summary.mapping_supplied);
1544        assert!(!in_place.identity_summary.all_sources_mapped);
1545        assert_eq!(in_place.identity_summary.mapped_members, 0);
1546
1547        let mapping = RestoreMapping {
1548            members: vec![
1549                RestoreMappingEntry {
1550                    source_canister: ROOT.to_string(),
1551                    target_canister: ROOT.to_string(),
1552                },
1553                RestoreMappingEntry {
1554                    source_canister: CHILD.to_string(),
1555                    target_canister: TARGET.to_string(),
1556                },
1557            ],
1558        };
1559        let mapped = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
1560
1561        assert!(mapped.identity_summary.mapping_supplied);
1562        assert!(mapped.identity_summary.all_sources_mapped);
1563        assert_eq!(mapped.identity_summary.mapped_members, 2);
1564        assert_eq!(mapped.identity_summary.remapped_members, 1);
1565    }
1566
1567    // Ensure restore plans summarize snapshot provenance completeness.
1568    #[test]
1569    fn plan_includes_snapshot_summary() {
1570        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1571        manifest.fleet.members[1].source_snapshot.module_hash = None;
1572        manifest.fleet.members[1].source_snapshot.wasm_hash = None;
1573        manifest.fleet.members[1].source_snapshot.checksum = None;
1574
1575        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1576
1577        assert!(!plan.snapshot_summary.all_members_have_module_hash);
1578        assert!(!plan.snapshot_summary.all_members_have_wasm_hash);
1579        assert!(plan.snapshot_summary.all_members_have_code_version);
1580        assert!(!plan.snapshot_summary.all_members_have_checksum);
1581        assert_eq!(plan.snapshot_summary.members_with_module_hash, 1);
1582        assert_eq!(plan.snapshot_summary.members_with_wasm_hash, 1);
1583        assert_eq!(plan.snapshot_summary.members_with_code_version, 2);
1584        assert_eq!(plan.snapshot_summary.members_with_checksum, 1);
1585        assert!(!plan.readiness_summary.ready);
1586        assert_eq!(
1587            plan.readiness_summary.reasons,
1588            [
1589                "missing-module-hash",
1590                "missing-wasm-hash",
1591                "missing-snapshot-checksum"
1592            ]
1593        );
1594    }
1595
1596    // Ensure restore plans summarize manifest-level verification work.
1597    #[test]
1598    fn plan_includes_verification_summary() {
1599        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1600        manifest.verification.fleet_checks.push(VerificationCheck {
1601            kind: "fleet-ready".to_string(),
1602            method: None,
1603            roles: Vec::new(),
1604        });
1605        manifest
1606            .verification
1607            .member_checks
1608            .push(MemberVerificationChecks {
1609                role: "app".to_string(),
1610                checks: vec![VerificationCheck {
1611                    kind: "app-ready".to_string(),
1612                    method: Some("ready".to_string()),
1613                    roles: Vec::new(),
1614                }],
1615            });
1616
1617        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1618
1619        assert!(plan.verification_summary.verification_required);
1620        assert!(plan.verification_summary.all_members_have_checks);
1621        assert_eq!(plan.verification_summary.fleet_checks, 1);
1622        assert_eq!(plan.verification_summary.member_check_groups, 1);
1623        assert_eq!(plan.verification_summary.member_checks, 3);
1624        assert_eq!(plan.verification_summary.members_with_checks, 2);
1625        assert_eq!(plan.verification_summary.total_checks, 4);
1626    }
1627
1628    // Ensure restore plans summarize the concrete operation counts automation will schedule.
1629    #[test]
1630    fn plan_includes_operation_summary() {
1631        let manifest = valid_manifest(IdentityMode::Relocatable);
1632
1633        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1634
1635        assert_eq!(plan.operation_summary.planned_snapshot_loads, 2);
1636        assert_eq!(plan.operation_summary.planned_code_reinstalls, 2);
1637        assert_eq!(plan.operation_summary.planned_verification_checks, 2);
1638        assert_eq!(plan.operation_summary.planned_phases, 1);
1639    }
1640
1641    // Ensure initial restore status mirrors the no-mutation restore plan.
1642    #[test]
1643    fn restore_status_starts_all_members_as_planned() {
1644        let manifest = valid_manifest(IdentityMode::Relocatable);
1645
1646        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1647        let status = RestoreStatus::from_plan(&plan);
1648
1649        assert_eq!(status.status_version, 1);
1650        assert_eq!(status.backup_id.as_str(), plan.backup_id.as_str());
1651        assert_eq!(
1652            status.source_environment.as_str(),
1653            plan.source_environment.as_str()
1654        );
1655        assert_eq!(
1656            status.source_root_canister.as_str(),
1657            plan.source_root_canister.as_str()
1658        );
1659        assert_eq!(status.topology_hash.as_str(), plan.topology_hash.as_str());
1660        assert!(status.ready);
1661        assert!(status.readiness_reasons.is_empty());
1662        assert!(status.verification_required);
1663        assert_eq!(status.member_count, 2);
1664        assert_eq!(status.phase_count, 1);
1665        assert_eq!(status.planned_snapshot_loads, 2);
1666        assert_eq!(status.planned_code_reinstalls, 2);
1667        assert_eq!(status.planned_verification_checks, 2);
1668        assert_eq!(status.phases.len(), 1);
1669        assert_eq!(status.phases[0].restore_group, 1);
1670        assert_eq!(status.phases[0].members.len(), 2);
1671        assert_eq!(
1672            status.phases[0].members[0].state,
1673            RestoreMemberState::Planned
1674        );
1675        assert_eq!(status.phases[0].members[0].source_canister, ROOT);
1676        assert_eq!(status.phases[0].members[0].target_canister, ROOT);
1677        assert_eq!(status.phases[0].members[0].snapshot_id, "snap-root");
1678        assert_eq!(status.phases[0].members[0].artifact_path, "artifacts/root");
1679        assert_eq!(
1680            status.phases[0].members[1].state,
1681            RestoreMemberState::Planned
1682        );
1683        assert_eq!(status.phases[0].members[1].source_canister, CHILD);
1684    }
1685
1686    // Ensure apply dry-runs render ordered operations without mutating targets.
1687    #[test]
1688    fn apply_dry_run_renders_ordered_member_operations() {
1689        let manifest = valid_manifest(IdentityMode::Relocatable);
1690
1691        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1692        let status = RestoreStatus::from_plan(&plan);
1693        let dry_run =
1694            RestoreApplyDryRun::try_from_plan(&plan, Some(&status)).expect("dry-run should build");
1695
1696        assert_eq!(dry_run.dry_run_version, 1);
1697        assert_eq!(dry_run.backup_id.as_str(), "fbk_test_001");
1698        assert!(dry_run.ready);
1699        assert!(dry_run.status_supplied);
1700        assert_eq!(dry_run.member_count, 2);
1701        assert_eq!(dry_run.phase_count, 1);
1702        assert_eq!(dry_run.planned_snapshot_loads, 2);
1703        assert_eq!(dry_run.planned_code_reinstalls, 2);
1704        assert_eq!(dry_run.planned_verification_checks, 2);
1705        assert_eq!(dry_run.rendered_operations, 8);
1706        assert_eq!(dry_run.phases.len(), 1);
1707
1708        let operations = &dry_run.phases[0].operations;
1709        assert_eq!(operations[0].sequence, 0);
1710        assert_eq!(
1711            operations[0].operation,
1712            RestoreApplyOperationKind::UploadSnapshot
1713        );
1714        assert_eq!(operations[0].source_canister, ROOT);
1715        assert_eq!(operations[0].target_canister, ROOT);
1716        assert_eq!(operations[0].snapshot_id, Some("snap-root".to_string()));
1717        assert_eq!(
1718            operations[0].artifact_path,
1719            Some("artifacts/root".to_string())
1720        );
1721        assert_eq!(
1722            operations[1].operation,
1723            RestoreApplyOperationKind::LoadSnapshot
1724        );
1725        assert_eq!(
1726            operations[2].operation,
1727            RestoreApplyOperationKind::ReinstallCode
1728        );
1729        assert_eq!(
1730            operations[3].operation,
1731            RestoreApplyOperationKind::VerifyMember
1732        );
1733        assert_eq!(operations[3].verification_kind, Some("call".to_string()));
1734        assert_eq!(
1735            operations[3].verification_method,
1736            Some("canic_ready".to_string())
1737        );
1738        assert_eq!(operations[4].source_canister, CHILD);
1739        assert_eq!(
1740            operations[7].operation,
1741            RestoreApplyOperationKind::VerifyMember
1742        );
1743    }
1744
1745    // Ensure apply dry-run operation sequences remain unique across phases.
1746    #[test]
1747    fn apply_dry_run_sequences_operations_across_phases() {
1748        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1749        manifest.fleet.members[0].restore_group = 2;
1750
1751        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1752        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
1753
1754        assert_eq!(dry_run.phases.len(), 2);
1755        assert_eq!(dry_run.rendered_operations, 8);
1756        assert_eq!(dry_run.phases[0].operations[0].sequence, 0);
1757        assert_eq!(dry_run.phases[0].operations[3].sequence, 3);
1758        assert_eq!(dry_run.phases[1].operations[0].sequence, 4);
1759        assert_eq!(dry_run.phases[1].operations[3].sequence, 7);
1760    }
1761
1762    // Ensure apply dry-runs can prove referenced artifacts exist and match checksums.
1763    #[test]
1764    fn apply_dry_run_validates_artifacts_under_backup_root() {
1765        let root = temp_dir("canic-restore-apply-artifacts-ok");
1766        fs::create_dir_all(&root).expect("create temp root");
1767        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1768        set_member_artifact(
1769            &mut manifest,
1770            CHILD,
1771            &root,
1772            "artifacts/child",
1773            b"child-snapshot",
1774        );
1775        set_member_artifact(
1776            &mut manifest,
1777            ROOT,
1778            &root,
1779            "artifacts/root",
1780            b"root-snapshot",
1781        );
1782
1783        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1784        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
1785            .expect("dry-run should validate artifacts");
1786
1787        let validation = dry_run
1788            .artifact_validation
1789            .expect("artifact validation should be present");
1790        assert_eq!(validation.checked_members, 2);
1791        assert!(validation.artifacts_present);
1792        assert!(validation.checksums_verified);
1793        assert_eq!(validation.members_with_expected_checksums, 2);
1794        assert_eq!(validation.checks[0].source_canister, ROOT);
1795        assert!(validation.checks[0].checksum_verified);
1796
1797        fs::remove_dir_all(root).expect("remove temp root");
1798    }
1799
1800    // Ensure apply dry-runs fail closed when a referenced artifact is missing.
1801    #[test]
1802    fn apply_dry_run_rejects_missing_artifacts() {
1803        let root = temp_dir("canic-restore-apply-artifacts-missing");
1804        fs::create_dir_all(&root).expect("create temp root");
1805        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1806        manifest.fleet.members[0].source_snapshot.artifact_path = "missing-child".to_string();
1807
1808        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1809        let err = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
1810            .expect_err("missing artifact should fail");
1811
1812        fs::remove_dir_all(root).expect("remove temp root");
1813        assert!(matches!(
1814            err,
1815            RestoreApplyDryRunError::ArtifactMissing { .. }
1816        ));
1817    }
1818
1819    // Ensure apply dry-runs reject artifact paths that escape the backup directory.
1820    #[test]
1821    fn apply_dry_run_rejects_artifact_path_traversal() {
1822        let root = temp_dir("canic-restore-apply-artifacts-traversal");
1823        fs::create_dir_all(&root).expect("create temp root");
1824        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1825        manifest.fleet.members[1].source_snapshot.artifact_path = "../outside".to_string();
1826
1827        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1828        let err = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
1829            .expect_err("path traversal should fail");
1830
1831        fs::remove_dir_all(root).expect("remove temp root");
1832        assert!(matches!(
1833            err,
1834            RestoreApplyDryRunError::ArtifactPathEscapesBackup { .. }
1835        ));
1836    }
1837
1838    // Ensure apply dry-runs reject status files that do not match the plan.
1839    #[test]
1840    fn apply_dry_run_rejects_mismatched_status() {
1841        let manifest = valid_manifest(IdentityMode::Relocatable);
1842
1843        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1844        let mut status = RestoreStatus::from_plan(&plan);
1845        status.backup_id = "other-backup".to_string();
1846
1847        let err = RestoreApplyDryRun::try_from_plan(&plan, Some(&status))
1848            .expect_err("mismatched status should fail");
1849
1850        assert!(matches!(
1851            err,
1852            RestoreApplyDryRunError::StatusPlanMismatch {
1853                field: "backup_id",
1854                ..
1855            }
1856        ));
1857    }
1858
1859    // Ensure role-level verification checks are counted once per matching member.
1860    #[test]
1861    fn plan_expands_role_verification_checks_per_matching_member() {
1862        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1863        manifest.fleet.members.push(fleet_member(
1864            "app",
1865            CHILD_TWO,
1866            Some(ROOT),
1867            IdentityMode::Relocatable,
1868            1,
1869        ));
1870        manifest
1871            .verification
1872            .member_checks
1873            .push(MemberVerificationChecks {
1874                role: "app".to_string(),
1875                checks: vec![VerificationCheck {
1876                    kind: "app-ready".to_string(),
1877                    method: Some("ready".to_string()),
1878                    roles: Vec::new(),
1879                }],
1880            });
1881
1882        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1883
1884        assert_eq!(plan.verification_summary.fleet_checks, 0);
1885        assert_eq!(plan.verification_summary.member_check_groups, 1);
1886        assert_eq!(plan.verification_summary.member_checks, 5);
1887        assert_eq!(plan.verification_summary.members_with_checks, 3);
1888        assert_eq!(plan.verification_summary.total_checks, 5);
1889    }
1890
1891    // Ensure mapped restores must cover every source member.
1892    #[test]
1893    fn mapped_restore_requires_complete_mapping() {
1894        let manifest = valid_manifest(IdentityMode::Relocatable);
1895        let mapping = RestoreMapping {
1896            members: vec![RestoreMappingEntry {
1897                source_canister: ROOT.to_string(),
1898                target_canister: ROOT.to_string(),
1899            }],
1900        };
1901
1902        let err = RestorePlanner::plan(&manifest, Some(&mapping))
1903            .expect_err("incomplete mapping should fail");
1904
1905        assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
1906    }
1907
1908    // Ensure mappings cannot silently include canisters outside the manifest.
1909    #[test]
1910    fn mapped_restore_rejects_unknown_mapping_sources() {
1911        let manifest = valid_manifest(IdentityMode::Relocatable);
1912        let unknown = "rdmx6-jaaaa-aaaaa-aaadq-cai";
1913        let mapping = RestoreMapping {
1914            members: vec![
1915                RestoreMappingEntry {
1916                    source_canister: ROOT.to_string(),
1917                    target_canister: ROOT.to_string(),
1918                },
1919                RestoreMappingEntry {
1920                    source_canister: CHILD.to_string(),
1921                    target_canister: TARGET.to_string(),
1922                },
1923                RestoreMappingEntry {
1924                    source_canister: unknown.to_string(),
1925                    target_canister: unknown.to_string(),
1926                },
1927            ],
1928        };
1929
1930        let err = RestorePlanner::plan(&manifest, Some(&mapping))
1931            .expect_err("unknown mapping source should fail");
1932
1933        assert!(matches!(err, RestorePlanError::UnknownMappingSource(_)));
1934    }
1935
1936    // Ensure duplicate target mappings fail before a plan is produced.
1937    #[test]
1938    fn duplicate_mapping_targets_fail_validation() {
1939        let manifest = valid_manifest(IdentityMode::Relocatable);
1940        let mapping = RestoreMapping {
1941            members: vec![
1942                RestoreMappingEntry {
1943                    source_canister: ROOT.to_string(),
1944                    target_canister: ROOT.to_string(),
1945                },
1946                RestoreMappingEntry {
1947                    source_canister: CHILD.to_string(),
1948                    target_canister: ROOT.to_string(),
1949                },
1950            ],
1951        };
1952
1953        let err = RestorePlanner::plan(&manifest, Some(&mapping))
1954            .expect_err("duplicate targets should fail");
1955
1956        assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
1957    }
1958
1959    // Write one artifact and record its path and checksum in the test manifest.
1960    fn set_member_artifact(
1961        manifest: &mut FleetBackupManifest,
1962        canister_id: &str,
1963        root: &Path,
1964        artifact_path: &str,
1965        bytes: &[u8],
1966    ) {
1967        let full_path = root.join(artifact_path);
1968        fs::create_dir_all(full_path.parent().expect("artifact parent")).expect("create parent");
1969        fs::write(&full_path, bytes).expect("write artifact");
1970        let checksum = ArtifactChecksum::from_bytes(bytes);
1971        let member = manifest
1972            .fleet
1973            .members
1974            .iter_mut()
1975            .find(|member| member.canister_id == canister_id)
1976            .expect("member should exist");
1977        member.source_snapshot.artifact_path = artifact_path.to_string();
1978        member.source_snapshot.checksum = Some(checksum.hash);
1979    }
1980
1981    // Return a unique temporary directory for restore tests.
1982    fn temp_dir(name: &str) -> PathBuf {
1983        let nanos = SystemTime::now()
1984            .duration_since(UNIX_EPOCH)
1985            .expect("system time should be after epoch")
1986            .as_nanos();
1987        env::temp_dir().join(format!("{name}-{nanos}"))
1988    }
1989}