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///
272/// RestoreApplyJournal
273///
274
275#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
276pub struct RestoreApplyJournal {
277    pub journal_version: u16,
278    pub backup_id: String,
279    pub ready: bool,
280    pub blocked_reasons: Vec<String>,
281    pub operation_count: usize,
282    pub pending_operations: usize,
283    pub ready_operations: usize,
284    pub blocked_operations: usize,
285    pub completed_operations: usize,
286    pub failed_operations: usize,
287    pub operations: Vec<RestoreApplyJournalOperation>,
288}
289
290impl RestoreApplyJournal {
291    /// Build the initial no-mutation restore apply journal from a dry-run.
292    #[must_use]
293    pub fn from_dry_run(dry_run: &RestoreApplyDryRun) -> Self {
294        let blocked_reasons = restore_apply_blocked_reasons(dry_run);
295        let initial_state = if blocked_reasons.is_empty() {
296            RestoreApplyOperationState::Ready
297        } else {
298            RestoreApplyOperationState::Blocked
299        };
300        let operations = dry_run
301            .phases
302            .iter()
303            .flat_map(|phase| phase.operations.iter())
304            .map(|operation| {
305                RestoreApplyJournalOperation::from_dry_run_operation(
306                    operation,
307                    initial_state.clone(),
308                    &blocked_reasons,
309                )
310            })
311            .collect::<Vec<_>>();
312        let ready_operations = operations
313            .iter()
314            .filter(|operation| operation.state == RestoreApplyOperationState::Ready)
315            .count();
316        let blocked_operations = operations
317            .iter()
318            .filter(|operation| operation.state == RestoreApplyOperationState::Blocked)
319            .count();
320
321        Self {
322            journal_version: 1,
323            backup_id: dry_run.backup_id.clone(),
324            ready: blocked_reasons.is_empty(),
325            blocked_reasons,
326            operation_count: operations.len(),
327            pending_operations: 0,
328            ready_operations,
329            blocked_operations,
330            completed_operations: 0,
331            failed_operations: 0,
332            operations,
333        }
334    }
335
336    /// Validate the structural consistency of a restore apply journal.
337    pub fn validate(&self) -> Result<(), RestoreApplyJournalError> {
338        validate_apply_journal_version(self.journal_version)?;
339        validate_apply_journal_nonempty("backup_id", &self.backup_id)?;
340        validate_apply_journal_count(
341            "operation_count",
342            self.operation_count,
343            self.operations.len(),
344        )?;
345
346        let state_counts = RestoreApplyJournalStateCounts::from_operations(&self.operations);
347        validate_apply_journal_count(
348            "pending_operations",
349            self.pending_operations,
350            state_counts.pending,
351        )?;
352        validate_apply_journal_count(
353            "ready_operations",
354            self.ready_operations,
355            state_counts.ready,
356        )?;
357        validate_apply_journal_count(
358            "blocked_operations",
359            self.blocked_operations,
360            state_counts.blocked,
361        )?;
362        validate_apply_journal_count(
363            "completed_operations",
364            self.completed_operations,
365            state_counts.completed,
366        )?;
367        validate_apply_journal_count(
368            "failed_operations",
369            self.failed_operations,
370            state_counts.failed,
371        )?;
372
373        if self.ready && (!self.blocked_reasons.is_empty() || self.blocked_operations > 0) {
374            return Err(RestoreApplyJournalError::ReadyJournalHasBlockingState);
375        }
376
377        validate_apply_journal_sequences(&self.operations)?;
378        for operation in &self.operations {
379            operation.validate()?;
380        }
381
382        Ok(())
383    }
384
385    /// Summarize this apply journal for operators and automation.
386    #[must_use]
387    pub fn status(&self) -> RestoreApplyJournalStatus {
388        RestoreApplyJournalStatus::from_journal(self)
389    }
390
391    /// Return the full next ready operation row, if one is available.
392    #[must_use]
393    pub fn next_ready_operation(&self) -> Option<&RestoreApplyJournalOperation> {
394        self.operations
395            .iter()
396            .filter(|operation| operation.state == RestoreApplyOperationState::Ready)
397            .min_by_key(|operation| operation.sequence)
398    }
399
400    /// Render the next ready operation as a compact runner-facing response.
401    #[must_use]
402    pub fn next_operation(&self) -> RestoreApplyNextOperation {
403        RestoreApplyNextOperation::from_journal(self)
404    }
405
406    /// Render the next ready operation as a concrete no-execute command preview.
407    #[must_use]
408    pub fn next_command_preview(&self) -> RestoreApplyCommandPreview {
409        RestoreApplyCommandPreview::from_journal(self)
410    }
411
412    /// Render the next ready operation as a configured no-execute command preview.
413    #[must_use]
414    pub fn next_command_preview_with_config(
415        &self,
416        config: &RestoreApplyCommandConfig,
417    ) -> RestoreApplyCommandPreview {
418        RestoreApplyCommandPreview::from_journal_with_config(self, config)
419    }
420
421    /// Mark one restore apply operation completed and refresh journal counts.
422    pub fn mark_operation_completed(
423        &mut self,
424        sequence: usize,
425    ) -> Result<(), RestoreApplyJournalError> {
426        self.transition_operation(sequence, RestoreApplyOperationState::Completed, Vec::new())
427    }
428
429    /// Mark one restore apply operation failed and refresh journal counts.
430    pub fn mark_operation_failed(
431        &mut self,
432        sequence: usize,
433        reason: String,
434    ) -> Result<(), RestoreApplyJournalError> {
435        if reason.trim().is_empty() {
436            return Err(RestoreApplyJournalError::FailureReasonRequired(sequence));
437        }
438
439        self.transition_operation(sequence, RestoreApplyOperationState::Failed, vec![reason])
440    }
441
442    // Apply one legal operation state transition and revalidate the journal.
443    fn transition_operation(
444        &mut self,
445        sequence: usize,
446        next_state: RestoreApplyOperationState,
447        blocking_reasons: Vec<String>,
448    ) -> Result<(), RestoreApplyJournalError> {
449        let index = self
450            .operations
451            .iter()
452            .position(|operation| operation.sequence == sequence)
453            .ok_or(RestoreApplyJournalError::OperationNotFound(sequence))?;
454        let operation = &self.operations[index];
455
456        if !operation.can_transition_to(&next_state) {
457            return Err(RestoreApplyJournalError::InvalidOperationTransition {
458                sequence,
459                from: operation.state.clone(),
460                to: next_state,
461            });
462        }
463
464        self.validate_operation_transition_order(operation, &next_state)?;
465
466        let operation = &mut self.operations[index];
467        operation.state = next_state;
468        operation.blocking_reasons = blocking_reasons;
469        self.refresh_operation_counts();
470        self.validate()
471    }
472
473    // Ensure fresh operation transitions advance in journal order.
474    fn validate_operation_transition_order(
475        &self,
476        operation: &RestoreApplyJournalOperation,
477        next_state: &RestoreApplyOperationState,
478    ) -> Result<(), RestoreApplyJournalError> {
479        if operation.state == *next_state {
480            return Ok(());
481        }
482
483        let next_sequence = self
484            .next_transition_sequence()
485            .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
486
487        if operation.sequence == next_sequence {
488            return Ok(());
489        }
490
491        Err(RestoreApplyJournalError::OutOfOrderOperationTransition {
492            requested: operation.sequence,
493            next: next_sequence,
494        })
495    }
496
497    // Return the next operation sequence that can be advanced by a runner.
498    fn next_transition_sequence(&self) -> Option<usize> {
499        self.operations
500            .iter()
501            .filter(|operation| {
502                matches!(
503                    operation.state,
504                    RestoreApplyOperationState::Ready | RestoreApplyOperationState::Pending
505                )
506            })
507            .map(|operation| operation.sequence)
508            .min()
509    }
510
511    // Recompute operation counts after a journal operation state change.
512    fn refresh_operation_counts(&mut self) {
513        let state_counts = RestoreApplyJournalStateCounts::from_operations(&self.operations);
514        self.operation_count = self.operations.len();
515        self.pending_operations = state_counts.pending;
516        self.ready_operations = state_counts.ready;
517        self.blocked_operations = state_counts.blocked;
518        self.completed_operations = state_counts.completed;
519        self.failed_operations = state_counts.failed;
520    }
521}
522
523// Validate the supported restore apply journal format version.
524const fn validate_apply_journal_version(version: u16) -> Result<(), RestoreApplyJournalError> {
525    if version == 1 {
526        return Ok(());
527    }
528
529    Err(RestoreApplyJournalError::UnsupportedVersion(version))
530}
531
532// Validate required nonempty restore apply journal fields.
533fn validate_apply_journal_nonempty(
534    field: &'static str,
535    value: &str,
536) -> Result<(), RestoreApplyJournalError> {
537    if !value.trim().is_empty() {
538        return Ok(());
539    }
540
541    Err(RestoreApplyJournalError::MissingField(field))
542}
543
544// Validate one reported restore apply journal count.
545const fn validate_apply_journal_count(
546    field: &'static str,
547    reported: usize,
548    actual: usize,
549) -> Result<(), RestoreApplyJournalError> {
550    if reported == actual {
551        return Ok(());
552    }
553
554    Err(RestoreApplyJournalError::CountMismatch {
555        field,
556        reported,
557        actual,
558    })
559}
560
561// Validate operation sequence values are unique and contiguous from zero.
562fn validate_apply_journal_sequences(
563    operations: &[RestoreApplyJournalOperation],
564) -> Result<(), RestoreApplyJournalError> {
565    let mut sequences = BTreeSet::new();
566    for operation in operations {
567        if !sequences.insert(operation.sequence) {
568            return Err(RestoreApplyJournalError::DuplicateSequence(
569                operation.sequence,
570            ));
571        }
572    }
573
574    for expected in 0..operations.len() {
575        if !sequences.contains(&expected) {
576            return Err(RestoreApplyJournalError::MissingSequence(expected));
577        }
578    }
579
580    Ok(())
581}
582
583///
584/// RestoreApplyJournalStateCounts
585///
586
587#[derive(Clone, Debug, Default, Eq, PartialEq)]
588struct RestoreApplyJournalStateCounts {
589    pending: usize,
590    ready: usize,
591    blocked: usize,
592    completed: usize,
593    failed: usize,
594}
595
596impl RestoreApplyJournalStateCounts {
597    // Count operation states from concrete journal operation rows.
598    fn from_operations(operations: &[RestoreApplyJournalOperation]) -> Self {
599        let mut counts = Self::default();
600        for operation in operations {
601            match operation.state {
602                RestoreApplyOperationState::Pending => counts.pending += 1,
603                RestoreApplyOperationState::Ready => counts.ready += 1,
604                RestoreApplyOperationState::Blocked => counts.blocked += 1,
605                RestoreApplyOperationState::Completed => counts.completed += 1,
606                RestoreApplyOperationState::Failed => counts.failed += 1,
607            }
608        }
609        counts
610    }
611}
612
613// Explain why an apply journal is blocked before mutation is allowed.
614fn restore_apply_blocked_reasons(dry_run: &RestoreApplyDryRun) -> Vec<String> {
615    let mut reasons = dry_run.readiness_reasons.clone();
616
617    match &dry_run.artifact_validation {
618        Some(validation) => {
619            if !validation.artifacts_present {
620                reasons.push("missing-artifacts".to_string());
621            }
622            if !validation.checksums_verified {
623                reasons.push("artifact-checksum-validation-incomplete".to_string());
624            }
625        }
626        None => reasons.push("missing-artifact-validation".to_string()),
627    }
628
629    reasons.sort();
630    reasons.dedup();
631    reasons
632}
633
634///
635/// RestoreApplyJournalStatus
636///
637
638#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
639pub struct RestoreApplyJournalStatus {
640    pub status_version: u16,
641    pub backup_id: String,
642    pub ready: bool,
643    pub complete: bool,
644    pub blocked_reasons: Vec<String>,
645    pub operation_count: usize,
646    pub pending_operations: usize,
647    pub ready_operations: usize,
648    pub blocked_operations: usize,
649    pub completed_operations: usize,
650    pub failed_operations: usize,
651    pub next_ready_sequence: Option<usize>,
652    pub next_ready_operation: Option<RestoreApplyOperationKind>,
653}
654
655impl RestoreApplyJournalStatus {
656    /// Build a compact status projection from a restore apply journal.
657    #[must_use]
658    pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
659        let next_ready = journal.next_ready_operation();
660
661        Self {
662            status_version: 1,
663            backup_id: journal.backup_id.clone(),
664            ready: journal.ready,
665            complete: journal.operation_count > 0
666                && journal.completed_operations == journal.operation_count,
667            blocked_reasons: journal.blocked_reasons.clone(),
668            operation_count: journal.operation_count,
669            pending_operations: journal.pending_operations,
670            ready_operations: journal.ready_operations,
671            blocked_operations: journal.blocked_operations,
672            completed_operations: journal.completed_operations,
673            failed_operations: journal.failed_operations,
674            next_ready_sequence: next_ready.map(|operation| operation.sequence),
675            next_ready_operation: next_ready.map(|operation| operation.operation.clone()),
676        }
677    }
678}
679
680///
681/// RestoreApplyNextOperation
682///
683
684#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
685pub struct RestoreApplyNextOperation {
686    pub response_version: u16,
687    pub backup_id: String,
688    pub ready: bool,
689    pub complete: bool,
690    pub operation_available: bool,
691    pub blocked_reasons: Vec<String>,
692    pub operation: Option<RestoreApplyJournalOperation>,
693}
694
695impl RestoreApplyNextOperation {
696    /// Build a compact next-operation response from a restore apply journal.
697    #[must_use]
698    pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
699        let complete =
700            journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
701        let operation = journal.next_ready_operation().cloned();
702
703        Self {
704            response_version: 1,
705            backup_id: journal.backup_id.clone(),
706            ready: journal.ready,
707            complete,
708            operation_available: operation.is_some(),
709            blocked_reasons: journal.blocked_reasons.clone(),
710            operation,
711        }
712    }
713}
714
715///
716/// RestoreApplyCommandPreview
717///
718
719#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
720#[expect(
721    clippy::struct_excessive_bools,
722    reason = "runner preview exposes machine-readable availability and safety flags"
723)]
724pub struct RestoreApplyCommandPreview {
725    pub response_version: u16,
726    pub backup_id: String,
727    pub ready: bool,
728    pub complete: bool,
729    pub operation_available: bool,
730    pub command_available: bool,
731    pub blocked_reasons: Vec<String>,
732    pub operation: Option<RestoreApplyJournalOperation>,
733    pub command: Option<RestoreApplyRunnerCommand>,
734}
735
736impl RestoreApplyCommandPreview {
737    /// Build a no-execute runner command preview from a restore apply journal.
738    #[must_use]
739    pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
740        Self::from_journal_with_config(journal, &RestoreApplyCommandConfig::default())
741    }
742
743    /// Build a configured no-execute runner command preview from a journal.
744    #[must_use]
745    pub fn from_journal_with_config(
746        journal: &RestoreApplyJournal,
747        config: &RestoreApplyCommandConfig,
748    ) -> Self {
749        let complete =
750            journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
751        let operation = journal.next_ready_operation().cloned();
752        let command = operation
753            .as_ref()
754            .and_then(|operation| RestoreApplyRunnerCommand::from_operation(operation, config));
755
756        Self {
757            response_version: 1,
758            backup_id: journal.backup_id.clone(),
759            ready: journal.ready,
760            complete,
761            operation_available: operation.is_some(),
762            command_available: command.is_some(),
763            blocked_reasons: journal.blocked_reasons.clone(),
764            operation,
765            command,
766        }
767    }
768}
769
770///
771/// RestoreApplyCommandConfig
772///
773
774#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
775pub struct RestoreApplyCommandConfig {
776    pub program: String,
777    pub network: Option<String>,
778}
779
780impl Default for RestoreApplyCommandConfig {
781    /// Build the default restore apply command preview configuration.
782    fn default() -> Self {
783        Self {
784            program: "dfx".to_string(),
785            network: None,
786        }
787    }
788}
789
790///
791/// RestoreApplyRunnerCommand
792///
793
794#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
795pub struct RestoreApplyRunnerCommand {
796    pub program: String,
797    pub args: Vec<String>,
798    pub mutates: bool,
799    pub requires_stopped_canister: bool,
800    pub note: String,
801}
802
803impl RestoreApplyRunnerCommand {
804    // Build a no-execute dfx command preview for one ready operation.
805    fn from_operation(
806        operation: &RestoreApplyJournalOperation,
807        config: &RestoreApplyCommandConfig,
808    ) -> Option<Self> {
809        match operation.operation {
810            RestoreApplyOperationKind::UploadSnapshot => {
811                let artifact_path = operation.artifact_path.as_ref()?;
812                Some(Self {
813                    program: config.program.clone(),
814                    args: dfx_canister_args(
815                        config,
816                        vec![
817                            "snapshot".to_string(),
818                            "upload".to_string(),
819                            "--dir".to_string(),
820                            artifact_path.clone(),
821                            operation.target_canister.clone(),
822                        ],
823                    ),
824                    mutates: true,
825                    requires_stopped_canister: false,
826                    note: "uploads the downloaded snapshot artifact to the target canister"
827                        .to_string(),
828                })
829            }
830            RestoreApplyOperationKind::LoadSnapshot => {
831                let snapshot_id = operation.snapshot_id.as_ref()?;
832                Some(Self {
833                    program: config.program.clone(),
834                    args: dfx_canister_args(
835                        config,
836                        vec![
837                            "snapshot".to_string(),
838                            "load".to_string(),
839                            operation.target_canister.clone(),
840                            snapshot_id.clone(),
841                        ],
842                    ),
843                    mutates: true,
844                    requires_stopped_canister: true,
845                    note: "loads the uploaded snapshot into the target canister".to_string(),
846                })
847            }
848            RestoreApplyOperationKind::ReinstallCode => Some(Self {
849                program: config.program.clone(),
850                args: dfx_canister_args(
851                    config,
852                    vec![
853                        "install".to_string(),
854                        "--mode".to_string(),
855                        "reinstall".to_string(),
856                        "--yes".to_string(),
857                        operation.target_canister.clone(),
858                    ],
859                ),
860                mutates: true,
861                requires_stopped_canister: false,
862                note: "reinstalls target canister code using the local dfx project configuration"
863                    .to_string(),
864            }),
865            RestoreApplyOperationKind::VerifyMember => {
866                match operation.verification_kind.as_deref() {
867                    Some("status") => Some(Self {
868                        program: config.program.clone(),
869                        args: dfx_canister_args(
870                            config,
871                            vec!["status".to_string(), operation.target_canister.clone()],
872                        ),
873                        mutates: false,
874                        requires_stopped_canister: false,
875                        note: "checks target canister status".to_string(),
876                    }),
877                    Some(_) => {
878                        let method = operation.verification_method.as_ref()?;
879                        Some(Self {
880                            program: config.program.clone(),
881                            args: dfx_canister_args(
882                                config,
883                                vec![
884                                    "call".to_string(),
885                                    operation.target_canister.clone(),
886                                    method.clone(),
887                                ],
888                            ),
889                            mutates: false,
890                            requires_stopped_canister: false,
891                            note: "calls the declared verification method".to_string(),
892                        })
893                    }
894                    None => None,
895                }
896            }
897        }
898    }
899}
900
901// Build `dfx canister` arguments with the optional network selector.
902fn dfx_canister_args(config: &RestoreApplyCommandConfig, mut tail: Vec<String>) -> Vec<String> {
903    let mut args = vec!["canister".to_string()];
904    if let Some(network) = &config.network {
905        args.push("--network".to_string());
906        args.push(network.clone());
907    }
908    args.append(&mut tail);
909    args
910}
911
912///
913/// RestoreApplyJournalOperation
914///
915
916#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
917pub struct RestoreApplyJournalOperation {
918    pub sequence: usize,
919    pub operation: RestoreApplyOperationKind,
920    pub state: RestoreApplyOperationState,
921    pub blocking_reasons: Vec<String>,
922    pub restore_group: u16,
923    pub phase_order: usize,
924    pub source_canister: String,
925    pub target_canister: String,
926    pub role: String,
927    pub snapshot_id: Option<String>,
928    pub artifact_path: Option<String>,
929    pub verification_kind: Option<String>,
930    pub verification_method: Option<String>,
931}
932
933impl RestoreApplyJournalOperation {
934    // Build one initial journal operation from the dry-run operation row.
935    fn from_dry_run_operation(
936        operation: &RestoreApplyDryRunOperation,
937        state: RestoreApplyOperationState,
938        blocked_reasons: &[String],
939    ) -> Self {
940        Self {
941            sequence: operation.sequence,
942            operation: operation.operation.clone(),
943            state: state.clone(),
944            blocking_reasons: if state == RestoreApplyOperationState::Blocked {
945                blocked_reasons.to_vec()
946            } else {
947                Vec::new()
948            },
949            restore_group: operation.restore_group,
950            phase_order: operation.phase_order,
951            source_canister: operation.source_canister.clone(),
952            target_canister: operation.target_canister.clone(),
953            role: operation.role.clone(),
954            snapshot_id: operation.snapshot_id.clone(),
955            artifact_path: operation.artifact_path.clone(),
956            verification_kind: operation.verification_kind.clone(),
957            verification_method: operation.verification_method.clone(),
958        }
959    }
960
961    // Validate one restore apply journal operation row.
962    fn validate(&self) -> Result<(), RestoreApplyJournalError> {
963        validate_apply_journal_nonempty("operations[].source_canister", &self.source_canister)?;
964        validate_apply_journal_nonempty("operations[].target_canister", &self.target_canister)?;
965        validate_apply_journal_nonempty("operations[].role", &self.role)?;
966
967        match self.state {
968            RestoreApplyOperationState::Blocked if self.blocking_reasons.is_empty() => Err(
969                RestoreApplyJournalError::BlockedOperationMissingReason(self.sequence),
970            ),
971            RestoreApplyOperationState::Failed if self.blocking_reasons.is_empty() => Err(
972                RestoreApplyJournalError::FailureReasonRequired(self.sequence),
973            ),
974            RestoreApplyOperationState::Pending
975            | RestoreApplyOperationState::Ready
976            | RestoreApplyOperationState::Completed
977                if !self.blocking_reasons.is_empty() =>
978            {
979                Err(RestoreApplyJournalError::UnblockedOperationHasReasons(
980                    self.sequence,
981                ))
982            }
983            RestoreApplyOperationState::Blocked
984            | RestoreApplyOperationState::Failed
985            | RestoreApplyOperationState::Pending
986            | RestoreApplyOperationState::Ready
987            | RestoreApplyOperationState::Completed => Ok(()),
988        }
989    }
990
991    // Decide whether an operation can move to the requested next state.
992    const fn can_transition_to(&self, next_state: &RestoreApplyOperationState) -> bool {
993        match (&self.state, next_state) {
994            (
995                RestoreApplyOperationState::Ready
996                | RestoreApplyOperationState::Pending
997                | RestoreApplyOperationState::Completed,
998                RestoreApplyOperationState::Completed,
999            )
1000            | (
1001                RestoreApplyOperationState::Ready
1002                | RestoreApplyOperationState::Pending
1003                | RestoreApplyOperationState::Failed,
1004                RestoreApplyOperationState::Failed,
1005            ) => true,
1006            (
1007                RestoreApplyOperationState::Blocked
1008                | RestoreApplyOperationState::Completed
1009                | RestoreApplyOperationState::Failed
1010                | RestoreApplyOperationState::Pending
1011                | RestoreApplyOperationState::Ready,
1012                _,
1013            ) => false,
1014        }
1015    }
1016}
1017
1018///
1019/// RestoreApplyOperationState
1020///
1021
1022#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1023#[serde(rename_all = "kebab-case")]
1024pub enum RestoreApplyOperationState {
1025    Pending,
1026    Ready,
1027    Blocked,
1028    Completed,
1029    Failed,
1030}
1031
1032///
1033/// RestoreApplyJournalError
1034///
1035
1036#[derive(Debug, ThisError)]
1037pub enum RestoreApplyJournalError {
1038    #[error("unsupported restore apply journal version {0}")]
1039    UnsupportedVersion(u16),
1040
1041    #[error("restore apply journal field {0} is required")]
1042    MissingField(&'static str),
1043
1044    #[error("restore apply journal count {field} mismatch: reported={reported}, actual={actual}")]
1045    CountMismatch {
1046        field: &'static str,
1047        reported: usize,
1048        actual: usize,
1049    },
1050
1051    #[error("restore apply journal has duplicate operation sequence {0}")]
1052    DuplicateSequence(usize),
1053
1054    #[error("restore apply journal is missing operation sequence {0}")]
1055    MissingSequence(usize),
1056
1057    #[error("ready restore apply journal cannot include blocked reasons or blocked operations")]
1058    ReadyJournalHasBlockingState,
1059
1060    #[error("blocked restore apply journal operation {0} is missing a blocking reason")]
1061    BlockedOperationMissingReason(usize),
1062
1063    #[error("unblocked restore apply journal operation {0} cannot have blocking reasons")]
1064    UnblockedOperationHasReasons(usize),
1065
1066    #[error("restore apply journal operation {0} was not found")]
1067    OperationNotFound(usize),
1068
1069    #[error("restore apply journal operation {sequence} cannot transition from {from:?} to {to:?}")]
1070    InvalidOperationTransition {
1071        sequence: usize,
1072        from: RestoreApplyOperationState,
1073        to: RestoreApplyOperationState,
1074    },
1075
1076    #[error("failed restore apply journal operation {0} requires a reason")]
1077    FailureReasonRequired(usize),
1078
1079    #[error("restore apply journal has no operation that can be advanced")]
1080    NoTransitionableOperation,
1081
1082    #[error("restore apply journal operation {requested} cannot advance before operation {next}")]
1083    OutOfOrderOperationTransition { requested: usize, next: usize },
1084}
1085
1086// Verify every planned restore artifact against one backup directory root.
1087fn validate_restore_apply_artifacts(
1088    plan: &RestorePlan,
1089    backup_root: &Path,
1090) -> Result<RestoreApplyArtifactValidation, RestoreApplyDryRunError> {
1091    let mut checks = Vec::new();
1092
1093    for member in plan.ordered_members() {
1094        checks.push(validate_restore_apply_artifact(member, backup_root)?);
1095    }
1096
1097    let members_with_expected_checksums = checks
1098        .iter()
1099        .filter(|check| check.checksum_expected.is_some())
1100        .count();
1101    let artifacts_present = checks.iter().all(|check| check.exists);
1102    let checksums_verified = members_with_expected_checksums == plan.member_count
1103        && checks.iter().all(|check| check.checksum_verified);
1104
1105    Ok(RestoreApplyArtifactValidation {
1106        backup_root: backup_root.to_string_lossy().to_string(),
1107        checked_members: checks.len(),
1108        artifacts_present,
1109        checksums_verified,
1110        members_with_expected_checksums,
1111        checks,
1112    })
1113}
1114
1115// Verify one planned restore artifact path and checksum.
1116fn validate_restore_apply_artifact(
1117    member: &RestorePlanMember,
1118    backup_root: &Path,
1119) -> Result<RestoreApplyArtifactCheck, RestoreApplyDryRunError> {
1120    let artifact_path = safe_restore_artifact_path(
1121        &member.source_canister,
1122        &member.source_snapshot.artifact_path,
1123    )?;
1124    let resolved_path = backup_root.join(&artifact_path);
1125
1126    if !resolved_path.exists() {
1127        return Err(RestoreApplyDryRunError::ArtifactMissing {
1128            source_canister: member.source_canister.clone(),
1129            artifact_path: member.source_snapshot.artifact_path.clone(),
1130            resolved_path: resolved_path.to_string_lossy().to_string(),
1131        });
1132    }
1133
1134    let (checksum_actual, checksum_verified) =
1135        if let Some(expected) = &member.source_snapshot.checksum {
1136            let checksum = ArtifactChecksum::from_path(&resolved_path).map_err(|source| {
1137                RestoreApplyDryRunError::ArtifactChecksum {
1138                    source_canister: member.source_canister.clone(),
1139                    artifact_path: member.source_snapshot.artifact_path.clone(),
1140                    source,
1141                }
1142            })?;
1143            checksum.verify(expected).map_err(|source| {
1144                RestoreApplyDryRunError::ArtifactChecksum {
1145                    source_canister: member.source_canister.clone(),
1146                    artifact_path: member.source_snapshot.artifact_path.clone(),
1147                    source,
1148                }
1149            })?;
1150            (Some(checksum.hash), true)
1151        } else {
1152            (None, false)
1153        };
1154
1155    Ok(RestoreApplyArtifactCheck {
1156        source_canister: member.source_canister.clone(),
1157        target_canister: member.target_canister.clone(),
1158        snapshot_id: member.source_snapshot.snapshot_id.clone(),
1159        artifact_path: member.source_snapshot.artifact_path.clone(),
1160        resolved_path: resolved_path.to_string_lossy().to_string(),
1161        exists: true,
1162        checksum_algorithm: member.source_snapshot.checksum_algorithm.clone(),
1163        checksum_expected: member.source_snapshot.checksum.clone(),
1164        checksum_actual,
1165        checksum_verified,
1166    })
1167}
1168
1169// Reject absolute paths and parent traversal before joining with the backup root.
1170fn safe_restore_artifact_path(
1171    source_canister: &str,
1172    artifact_path: &str,
1173) -> Result<PathBuf, RestoreApplyDryRunError> {
1174    let path = Path::new(artifact_path);
1175    let is_safe = path
1176        .components()
1177        .all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
1178
1179    if is_safe {
1180        return Ok(path.to_path_buf());
1181    }
1182
1183    Err(RestoreApplyDryRunError::ArtifactPathEscapesBackup {
1184        source_canister: source_canister.to_string(),
1185        artifact_path: artifact_path.to_string(),
1186    })
1187}
1188
1189// Validate that a supplied restore status belongs to the restore plan.
1190fn validate_restore_status_matches_plan(
1191    plan: &RestorePlan,
1192    status: &RestoreStatus,
1193) -> Result<(), RestoreApplyDryRunError> {
1194    validate_status_string_field("backup_id", &plan.backup_id, &status.backup_id)?;
1195    validate_status_string_field(
1196        "source_environment",
1197        &plan.source_environment,
1198        &status.source_environment,
1199    )?;
1200    validate_status_string_field(
1201        "source_root_canister",
1202        &plan.source_root_canister,
1203        &status.source_root_canister,
1204    )?;
1205    validate_status_string_field("topology_hash", &plan.topology_hash, &status.topology_hash)?;
1206    validate_status_usize_field("member_count", plan.member_count, status.member_count)?;
1207    validate_status_usize_field(
1208        "phase_count",
1209        plan.ordering_summary.phase_count,
1210        status.phase_count,
1211    )?;
1212    Ok(())
1213}
1214
1215// Validate one string field shared by restore plan and status.
1216fn validate_status_string_field(
1217    field: &'static str,
1218    plan: &str,
1219    status: &str,
1220) -> Result<(), RestoreApplyDryRunError> {
1221    if plan == status {
1222        return Ok(());
1223    }
1224
1225    Err(RestoreApplyDryRunError::StatusPlanMismatch {
1226        field,
1227        plan: plan.to_string(),
1228        status: status.to_string(),
1229    })
1230}
1231
1232// Validate one numeric field shared by restore plan and status.
1233const fn validate_status_usize_field(
1234    field: &'static str,
1235    plan: usize,
1236    status: usize,
1237) -> Result<(), RestoreApplyDryRunError> {
1238    if plan == status {
1239        return Ok(());
1240    }
1241
1242    Err(RestoreApplyDryRunError::StatusPlanCountMismatch {
1243        field,
1244        plan,
1245        status,
1246    })
1247}
1248
1249///
1250/// RestoreApplyArtifactValidation
1251///
1252
1253#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1254pub struct RestoreApplyArtifactValidation {
1255    pub backup_root: String,
1256    pub checked_members: usize,
1257    pub artifacts_present: bool,
1258    pub checksums_verified: bool,
1259    pub members_with_expected_checksums: usize,
1260    pub checks: Vec<RestoreApplyArtifactCheck>,
1261}
1262
1263///
1264/// RestoreApplyArtifactCheck
1265///
1266
1267#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1268pub struct RestoreApplyArtifactCheck {
1269    pub source_canister: String,
1270    pub target_canister: String,
1271    pub snapshot_id: String,
1272    pub artifact_path: String,
1273    pub resolved_path: String,
1274    pub exists: bool,
1275    pub checksum_algorithm: String,
1276    pub checksum_expected: Option<String>,
1277    pub checksum_actual: Option<String>,
1278    pub checksum_verified: bool,
1279}
1280
1281///
1282/// RestoreApplyDryRunPhase
1283///
1284
1285#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1286pub struct RestoreApplyDryRunPhase {
1287    pub restore_group: u16,
1288    pub operations: Vec<RestoreApplyDryRunOperation>,
1289}
1290
1291impl RestoreApplyDryRunPhase {
1292    // Build one dry-run phase from one restore plan phase.
1293    fn from_plan_phase(phase: &RestorePhase, next_sequence: &mut usize) -> Self {
1294        let mut operations = Vec::new();
1295
1296        for member in &phase.members {
1297            push_member_operation(
1298                &mut operations,
1299                next_sequence,
1300                RestoreApplyOperationKind::UploadSnapshot,
1301                member,
1302                None,
1303            );
1304            push_member_operation(
1305                &mut operations,
1306                next_sequence,
1307                RestoreApplyOperationKind::LoadSnapshot,
1308                member,
1309                None,
1310            );
1311            push_member_operation(
1312                &mut operations,
1313                next_sequence,
1314                RestoreApplyOperationKind::ReinstallCode,
1315                member,
1316                None,
1317            );
1318
1319            for check in &member.verification_checks {
1320                push_member_operation(
1321                    &mut operations,
1322                    next_sequence,
1323                    RestoreApplyOperationKind::VerifyMember,
1324                    member,
1325                    Some(check),
1326                );
1327            }
1328        }
1329
1330        Self {
1331            restore_group: phase.restore_group,
1332            operations,
1333        }
1334    }
1335}
1336
1337// Append one member-level dry-run operation using the current phase order.
1338fn push_member_operation(
1339    operations: &mut Vec<RestoreApplyDryRunOperation>,
1340    next_sequence: &mut usize,
1341    operation: RestoreApplyOperationKind,
1342    member: &RestorePlanMember,
1343    check: Option<&VerificationCheck>,
1344) {
1345    let sequence = *next_sequence;
1346    *next_sequence += 1;
1347
1348    operations.push(RestoreApplyDryRunOperation {
1349        sequence,
1350        operation,
1351        restore_group: member.restore_group,
1352        phase_order: member.phase_order,
1353        source_canister: member.source_canister.clone(),
1354        target_canister: member.target_canister.clone(),
1355        role: member.role.clone(),
1356        snapshot_id: Some(member.source_snapshot.snapshot_id.clone()),
1357        artifact_path: Some(member.source_snapshot.artifact_path.clone()),
1358        verification_kind: check.map(|check| check.kind.clone()),
1359        verification_method: check.and_then(|check| check.method.clone()),
1360    });
1361}
1362
1363///
1364/// RestoreApplyDryRunOperation
1365///
1366
1367#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1368pub struct RestoreApplyDryRunOperation {
1369    pub sequence: usize,
1370    pub operation: RestoreApplyOperationKind,
1371    pub restore_group: u16,
1372    pub phase_order: usize,
1373    pub source_canister: String,
1374    pub target_canister: String,
1375    pub role: String,
1376    pub snapshot_id: Option<String>,
1377    pub artifact_path: Option<String>,
1378    pub verification_kind: Option<String>,
1379    pub verification_method: Option<String>,
1380}
1381
1382///
1383/// RestoreApplyOperationKind
1384///
1385
1386#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1387#[serde(rename_all = "kebab-case")]
1388pub enum RestoreApplyOperationKind {
1389    UploadSnapshot,
1390    LoadSnapshot,
1391    ReinstallCode,
1392    VerifyMember,
1393}
1394
1395///
1396/// RestoreApplyDryRunError
1397///
1398
1399#[derive(Debug, ThisError)]
1400pub enum RestoreApplyDryRunError {
1401    #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
1402    StatusPlanMismatch {
1403        field: &'static str,
1404        plan: String,
1405        status: String,
1406    },
1407
1408    #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
1409    StatusPlanCountMismatch {
1410        field: &'static str,
1411        plan: usize,
1412        status: usize,
1413    },
1414
1415    #[error("restore artifact path for {source_canister} escapes backup root: {artifact_path}")]
1416    ArtifactPathEscapesBackup {
1417        source_canister: String,
1418        artifact_path: String,
1419    },
1420
1421    #[error(
1422        "restore artifact for {source_canister} is missing: {artifact_path} at {resolved_path}"
1423    )]
1424    ArtifactMissing {
1425        source_canister: String,
1426        artifact_path: String,
1427        resolved_path: String,
1428    },
1429
1430    #[error("restore artifact checksum failed for {source_canister} at {artifact_path}: {source}")]
1431    ArtifactChecksum {
1432        source_canister: String,
1433        artifact_path: String,
1434        #[source]
1435        source: ArtifactChecksumError,
1436    },
1437}
1438
1439///
1440/// RestoreIdentitySummary
1441///
1442
1443#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1444pub struct RestoreIdentitySummary {
1445    pub mapping_supplied: bool,
1446    pub all_sources_mapped: bool,
1447    pub fixed_members: usize,
1448    pub relocatable_members: usize,
1449    pub in_place_members: usize,
1450    pub mapped_members: usize,
1451    pub remapped_members: usize,
1452}
1453
1454///
1455/// RestoreSnapshotSummary
1456///
1457
1458#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1459#[expect(
1460    clippy::struct_excessive_bools,
1461    reason = "restore summaries intentionally expose machine-readable readiness flags"
1462)]
1463pub struct RestoreSnapshotSummary {
1464    pub all_members_have_module_hash: bool,
1465    pub all_members_have_wasm_hash: bool,
1466    pub all_members_have_code_version: bool,
1467    pub all_members_have_checksum: bool,
1468    pub members_with_module_hash: usize,
1469    pub members_with_wasm_hash: usize,
1470    pub members_with_code_version: usize,
1471    pub members_with_checksum: usize,
1472}
1473
1474///
1475/// RestoreVerificationSummary
1476///
1477
1478#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1479pub struct RestoreVerificationSummary {
1480    pub verification_required: bool,
1481    pub all_members_have_checks: bool,
1482    pub fleet_checks: usize,
1483    pub member_check_groups: usize,
1484    pub member_checks: usize,
1485    pub members_with_checks: usize,
1486    pub total_checks: usize,
1487}
1488
1489///
1490/// RestoreReadinessSummary
1491///
1492
1493#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1494pub struct RestoreReadinessSummary {
1495    pub ready: bool,
1496    pub reasons: Vec<String>,
1497}
1498
1499///
1500/// RestoreOperationSummary
1501///
1502
1503#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1504pub struct RestoreOperationSummary {
1505    pub planned_snapshot_loads: usize,
1506    pub planned_code_reinstalls: usize,
1507    pub planned_verification_checks: usize,
1508    pub planned_phases: usize,
1509}
1510
1511///
1512/// RestoreOrderingSummary
1513///
1514
1515#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1516pub struct RestoreOrderingSummary {
1517    pub phase_count: usize,
1518    pub dependency_free_members: usize,
1519    pub in_group_parent_edges: usize,
1520    pub cross_group_parent_edges: usize,
1521}
1522
1523///
1524/// RestorePhase
1525///
1526
1527#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1528pub struct RestorePhase {
1529    pub restore_group: u16,
1530    pub members: Vec<RestorePlanMember>,
1531}
1532
1533///
1534/// RestorePlanMember
1535///
1536
1537#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1538pub struct RestorePlanMember {
1539    pub source_canister: String,
1540    pub target_canister: String,
1541    pub role: String,
1542    pub parent_source_canister: Option<String>,
1543    pub parent_target_canister: Option<String>,
1544    pub ordering_dependency: Option<RestoreOrderingDependency>,
1545    pub phase_order: usize,
1546    pub restore_group: u16,
1547    pub identity_mode: IdentityMode,
1548    pub verification_class: String,
1549    pub verification_checks: Vec<VerificationCheck>,
1550    pub source_snapshot: SourceSnapshot,
1551}
1552
1553///
1554/// RestoreOrderingDependency
1555///
1556
1557#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1558pub struct RestoreOrderingDependency {
1559    pub source_canister: String,
1560    pub target_canister: String,
1561    pub relationship: RestoreOrderingRelationship,
1562}
1563
1564///
1565/// RestoreOrderingRelationship
1566///
1567
1568#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1569#[serde(rename_all = "kebab-case")]
1570pub enum RestoreOrderingRelationship {
1571    ParentInSameGroup,
1572    ParentInEarlierGroup,
1573}
1574
1575///
1576/// RestorePlanner
1577///
1578
1579pub struct RestorePlanner;
1580
1581impl RestorePlanner {
1582    /// Build a no-mutation restore plan from the manifest and optional target mapping.
1583    pub fn plan(
1584        manifest: &FleetBackupManifest,
1585        mapping: Option<&RestoreMapping>,
1586    ) -> Result<RestorePlan, RestorePlanError> {
1587        manifest.validate()?;
1588        if let Some(mapping) = mapping {
1589            validate_mapping(mapping)?;
1590            validate_mapping_sources(manifest, mapping)?;
1591        }
1592
1593        let members = resolve_members(manifest, mapping)?;
1594        let identity_summary = restore_identity_summary(&members, mapping.is_some());
1595        let snapshot_summary = restore_snapshot_summary(&members);
1596        let verification_summary = restore_verification_summary(manifest, &members);
1597        let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
1598        validate_restore_group_dependencies(&members)?;
1599        let phases = group_and_order_members(members)?;
1600        let ordering_summary = restore_ordering_summary(&phases);
1601        let operation_summary =
1602            restore_operation_summary(manifest.fleet.members.len(), &verification_summary, &phases);
1603
1604        Ok(RestorePlan {
1605            backup_id: manifest.backup_id.clone(),
1606            source_environment: manifest.source.environment.clone(),
1607            source_root_canister: manifest.source.root_canister.clone(),
1608            topology_hash: manifest.fleet.topology_hash.clone(),
1609            member_count: manifest.fleet.members.len(),
1610            identity_summary,
1611            snapshot_summary,
1612            verification_summary,
1613            readiness_summary,
1614            operation_summary,
1615            ordering_summary,
1616            phases,
1617        })
1618    }
1619}
1620
1621///
1622/// RestorePlanError
1623///
1624
1625#[derive(Debug, ThisError)]
1626pub enum RestorePlanError {
1627    #[error(transparent)]
1628    InvalidManifest(#[from] ManifestValidationError),
1629
1630    #[error("field {field} must be a valid principal: {value}")]
1631    InvalidPrincipal { field: &'static str, value: String },
1632
1633    #[error("mapping contains duplicate source canister {0}")]
1634    DuplicateMappingSource(String),
1635
1636    #[error("mapping contains duplicate target canister {0}")]
1637    DuplicateMappingTarget(String),
1638
1639    #[error("mapping references unknown source canister {0}")]
1640    UnknownMappingSource(String),
1641
1642    #[error("mapping is missing source canister {0}")]
1643    MissingMappingSource(String),
1644
1645    #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
1646    FixedIdentityRemap {
1647        source_canister: String,
1648        target_canister: String,
1649    },
1650
1651    #[error("restore plan contains duplicate target canister {0}")]
1652    DuplicatePlanTarget(String),
1653
1654    #[error("restore group {0} contains a parent cycle or unresolved dependency")]
1655    RestoreOrderCycle(u16),
1656
1657    #[error(
1658        "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
1659    )]
1660    ParentRestoreGroupAfterChild {
1661        child_source_canister: String,
1662        parent_source_canister: String,
1663        child_restore_group: u16,
1664        parent_restore_group: u16,
1665    },
1666}
1667
1668// Validate a user-supplied restore mapping before applying it to the manifest.
1669fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
1670    let mut sources = BTreeSet::new();
1671    let mut targets = BTreeSet::new();
1672
1673    for entry in &mapping.members {
1674        validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
1675        validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
1676
1677        if !sources.insert(entry.source_canister.clone()) {
1678            return Err(RestorePlanError::DuplicateMappingSource(
1679                entry.source_canister.clone(),
1680            ));
1681        }
1682
1683        if !targets.insert(entry.target_canister.clone()) {
1684            return Err(RestorePlanError::DuplicateMappingTarget(
1685                entry.target_canister.clone(),
1686            ));
1687        }
1688    }
1689
1690    Ok(())
1691}
1692
1693// Ensure mappings only reference members declared in the manifest.
1694fn validate_mapping_sources(
1695    manifest: &FleetBackupManifest,
1696    mapping: &RestoreMapping,
1697) -> Result<(), RestorePlanError> {
1698    let sources = manifest
1699        .fleet
1700        .members
1701        .iter()
1702        .map(|member| member.canister_id.as_str())
1703        .collect::<BTreeSet<_>>();
1704
1705    for entry in &mapping.members {
1706        if !sources.contains(entry.source_canister.as_str()) {
1707            return Err(RestorePlanError::UnknownMappingSource(
1708                entry.source_canister.clone(),
1709            ));
1710        }
1711    }
1712
1713    Ok(())
1714}
1715
1716// Resolve source manifest members into target restore members.
1717fn resolve_members(
1718    manifest: &FleetBackupManifest,
1719    mapping: Option<&RestoreMapping>,
1720) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
1721    let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
1722    let mut targets = BTreeSet::new();
1723    let mut source_to_target = BTreeMap::new();
1724
1725    for member in &manifest.fleet.members {
1726        let target = resolve_target(member, mapping)?;
1727        if !targets.insert(target.clone()) {
1728            return Err(RestorePlanError::DuplicatePlanTarget(target));
1729        }
1730
1731        source_to_target.insert(member.canister_id.clone(), target.clone());
1732        plan_members.push(RestorePlanMember {
1733            source_canister: member.canister_id.clone(),
1734            target_canister: target,
1735            role: member.role.clone(),
1736            parent_source_canister: member.parent_canister_id.clone(),
1737            parent_target_canister: None,
1738            ordering_dependency: None,
1739            phase_order: 0,
1740            restore_group: member.restore_group,
1741            identity_mode: member.identity_mode.clone(),
1742            verification_class: member.verification_class.clone(),
1743            verification_checks: member.verification_checks.clone(),
1744            source_snapshot: member.source_snapshot.clone(),
1745        });
1746    }
1747
1748    for member in &mut plan_members {
1749        member.parent_target_canister = member
1750            .parent_source_canister
1751            .as_ref()
1752            .and_then(|parent| source_to_target.get(parent))
1753            .cloned();
1754    }
1755
1756    Ok(plan_members)
1757}
1758
1759// Resolve one member's target canister, enforcing identity continuity.
1760fn resolve_target(
1761    member: &FleetMember,
1762    mapping: Option<&RestoreMapping>,
1763) -> Result<String, RestorePlanError> {
1764    let target = match mapping {
1765        Some(mapping) => mapping
1766            .target_for(&member.canister_id)
1767            .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
1768            .to_string(),
1769        None => member.canister_id.clone(),
1770    };
1771
1772    if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
1773        return Err(RestorePlanError::FixedIdentityRemap {
1774            source_canister: member.canister_id.clone(),
1775            target_canister: target,
1776        });
1777    }
1778
1779    Ok(target)
1780}
1781
1782// Summarize identity and mapping decisions before grouping restore phases.
1783fn restore_identity_summary(
1784    members: &[RestorePlanMember],
1785    mapping_supplied: bool,
1786) -> RestoreIdentitySummary {
1787    let mut summary = RestoreIdentitySummary {
1788        mapping_supplied,
1789        all_sources_mapped: false,
1790        fixed_members: 0,
1791        relocatable_members: 0,
1792        in_place_members: 0,
1793        mapped_members: 0,
1794        remapped_members: 0,
1795    };
1796
1797    for member in members {
1798        match member.identity_mode {
1799            IdentityMode::Fixed => summary.fixed_members += 1,
1800            IdentityMode::Relocatable => summary.relocatable_members += 1,
1801        }
1802
1803        if member.source_canister == member.target_canister {
1804            summary.in_place_members += 1;
1805        } else {
1806            summary.remapped_members += 1;
1807        }
1808        if mapping_supplied {
1809            summary.mapped_members += 1;
1810        }
1811    }
1812
1813    summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
1814
1815    summary
1816}
1817
1818// Summarize snapshot provenance completeness before grouping restore phases.
1819fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
1820    let members_with_module_hash = members
1821        .iter()
1822        .filter(|member| member.source_snapshot.module_hash.is_some())
1823        .count();
1824    let members_with_wasm_hash = members
1825        .iter()
1826        .filter(|member| member.source_snapshot.wasm_hash.is_some())
1827        .count();
1828    let members_with_code_version = members
1829        .iter()
1830        .filter(|member| member.source_snapshot.code_version.is_some())
1831        .count();
1832    let members_with_checksum = members
1833        .iter()
1834        .filter(|member| member.source_snapshot.checksum.is_some())
1835        .count();
1836
1837    RestoreSnapshotSummary {
1838        all_members_have_module_hash: members_with_module_hash == members.len(),
1839        all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
1840        all_members_have_code_version: members_with_code_version == members.len(),
1841        all_members_have_checksum: members_with_checksum == members.len(),
1842        members_with_module_hash,
1843        members_with_wasm_hash,
1844        members_with_code_version,
1845        members_with_checksum,
1846    }
1847}
1848
1849// Summarize whether restore planning has the metadata required for automation.
1850fn restore_readiness_summary(
1851    snapshot: &RestoreSnapshotSummary,
1852    verification: &RestoreVerificationSummary,
1853) -> RestoreReadinessSummary {
1854    let mut reasons = Vec::new();
1855
1856    if !snapshot.all_members_have_module_hash {
1857        reasons.push("missing-module-hash".to_string());
1858    }
1859    if !snapshot.all_members_have_wasm_hash {
1860        reasons.push("missing-wasm-hash".to_string());
1861    }
1862    if !snapshot.all_members_have_code_version {
1863        reasons.push("missing-code-version".to_string());
1864    }
1865    if !snapshot.all_members_have_checksum {
1866        reasons.push("missing-snapshot-checksum".to_string());
1867    }
1868    if !verification.all_members_have_checks {
1869        reasons.push("missing-verification-checks".to_string());
1870    }
1871
1872    RestoreReadinessSummary {
1873        ready: reasons.is_empty(),
1874        reasons,
1875    }
1876}
1877
1878// Summarize restore verification work declared by the manifest and members.
1879fn restore_verification_summary(
1880    manifest: &FleetBackupManifest,
1881    members: &[RestorePlanMember],
1882) -> RestoreVerificationSummary {
1883    let fleet_checks = manifest.verification.fleet_checks.len();
1884    let member_check_groups = manifest.verification.member_checks.len();
1885    let role_check_counts = manifest
1886        .verification
1887        .member_checks
1888        .iter()
1889        .map(|group| (group.role.as_str(), group.checks.len()))
1890        .collect::<BTreeMap<_, _>>();
1891    let inline_member_checks = members
1892        .iter()
1893        .map(|member| member.verification_checks.len())
1894        .sum::<usize>();
1895    let role_member_checks = members
1896        .iter()
1897        .map(|member| {
1898            role_check_counts
1899                .get(member.role.as_str())
1900                .copied()
1901                .unwrap_or(0)
1902        })
1903        .sum::<usize>();
1904    let member_checks = inline_member_checks + role_member_checks;
1905    let members_with_checks = members
1906        .iter()
1907        .filter(|member| {
1908            !member.verification_checks.is_empty()
1909                || role_check_counts.contains_key(member.role.as_str())
1910        })
1911        .count();
1912
1913    RestoreVerificationSummary {
1914        verification_required: true,
1915        all_members_have_checks: members_with_checks == members.len(),
1916        fleet_checks,
1917        member_check_groups,
1918        member_checks,
1919        members_with_checks,
1920        total_checks: fleet_checks + member_checks,
1921    }
1922}
1923
1924// Summarize the concrete restore operations implied by a no-mutation plan.
1925const fn restore_operation_summary(
1926    member_count: usize,
1927    verification_summary: &RestoreVerificationSummary,
1928    phases: &[RestorePhase],
1929) -> RestoreOperationSummary {
1930    RestoreOperationSummary {
1931        planned_snapshot_loads: member_count,
1932        planned_code_reinstalls: member_count,
1933        planned_verification_checks: verification_summary.total_checks,
1934        planned_phases: phases.len(),
1935    }
1936}
1937
1938// Reject group assignments that would restore a child before its parent.
1939fn validate_restore_group_dependencies(
1940    members: &[RestorePlanMember],
1941) -> Result<(), RestorePlanError> {
1942    let groups_by_source = members
1943        .iter()
1944        .map(|member| (member.source_canister.as_str(), member.restore_group))
1945        .collect::<BTreeMap<_, _>>();
1946
1947    for member in members {
1948        let Some(parent) = &member.parent_source_canister else {
1949            continue;
1950        };
1951        let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
1952            continue;
1953        };
1954
1955        if *parent_group > member.restore_group {
1956            return Err(RestorePlanError::ParentRestoreGroupAfterChild {
1957                child_source_canister: member.source_canister.clone(),
1958                parent_source_canister: parent.clone(),
1959                child_restore_group: member.restore_group,
1960                parent_restore_group: *parent_group,
1961            });
1962        }
1963    }
1964
1965    Ok(())
1966}
1967
1968// Group members and apply parent-before-child ordering inside each group.
1969fn group_and_order_members(
1970    members: Vec<RestorePlanMember>,
1971) -> Result<Vec<RestorePhase>, RestorePlanError> {
1972    let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
1973    for member in members {
1974        groups.entry(member.restore_group).or_default().push(member);
1975    }
1976
1977    groups
1978        .into_iter()
1979        .map(|(restore_group, members)| {
1980            let members = order_group(restore_group, members)?;
1981            Ok(RestorePhase {
1982                restore_group,
1983                members,
1984            })
1985        })
1986        .collect()
1987}
1988
1989// Topologically order one group using manifest parent relationships.
1990fn order_group(
1991    restore_group: u16,
1992    members: Vec<RestorePlanMember>,
1993) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
1994    let mut remaining = members;
1995    let group_sources = remaining
1996        .iter()
1997        .map(|member| member.source_canister.clone())
1998        .collect::<BTreeSet<_>>();
1999    let mut emitted = BTreeSet::new();
2000    let mut ordered = Vec::with_capacity(remaining.len());
2001
2002    while !remaining.is_empty() {
2003        let Some(index) = remaining
2004            .iter()
2005            .position(|member| parent_satisfied(member, &group_sources, &emitted))
2006        else {
2007            return Err(RestorePlanError::RestoreOrderCycle(restore_group));
2008        };
2009
2010        let mut member = remaining.remove(index);
2011        member.phase_order = ordered.len();
2012        member.ordering_dependency = ordering_dependency(&member, &group_sources);
2013        emitted.insert(member.source_canister.clone());
2014        ordered.push(member);
2015    }
2016
2017    Ok(ordered)
2018}
2019
2020// Describe the topology dependency that controlled a member's restore ordering.
2021fn ordering_dependency(
2022    member: &RestorePlanMember,
2023    group_sources: &BTreeSet<String>,
2024) -> Option<RestoreOrderingDependency> {
2025    let parent_source = member.parent_source_canister.as_ref()?;
2026    let parent_target = member.parent_target_canister.as_ref()?;
2027    let relationship = if group_sources.contains(parent_source) {
2028        RestoreOrderingRelationship::ParentInSameGroup
2029    } else {
2030        RestoreOrderingRelationship::ParentInEarlierGroup
2031    };
2032
2033    Some(RestoreOrderingDependency {
2034        source_canister: parent_source.clone(),
2035        target_canister: parent_target.clone(),
2036        relationship,
2037    })
2038}
2039
2040// Summarize the dependency ordering metadata exposed in the restore plan.
2041fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
2042    let mut summary = RestoreOrderingSummary {
2043        phase_count: phases.len(),
2044        dependency_free_members: 0,
2045        in_group_parent_edges: 0,
2046        cross_group_parent_edges: 0,
2047    };
2048
2049    for member in phases.iter().flat_map(|phase| phase.members.iter()) {
2050        match &member.ordering_dependency {
2051            Some(dependency)
2052                if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
2053            {
2054                summary.in_group_parent_edges += 1;
2055            }
2056            Some(dependency)
2057                if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
2058            {
2059                summary.cross_group_parent_edges += 1;
2060            }
2061            Some(_) => {}
2062            None => summary.dependency_free_members += 1,
2063        }
2064    }
2065
2066    summary
2067}
2068
2069// Determine whether a member's in-group parent has already been emitted.
2070fn parent_satisfied(
2071    member: &RestorePlanMember,
2072    group_sources: &BTreeSet<String>,
2073    emitted: &BTreeSet<String>,
2074) -> bool {
2075    match &member.parent_source_canister {
2076        Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
2077        _ => true,
2078    }
2079}
2080
2081// Validate textual principal fields used in mappings.
2082fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
2083    Principal::from_str(value)
2084        .map(|_| ())
2085        .map_err(|_| RestorePlanError::InvalidPrincipal {
2086            field,
2087            value: value.to_string(),
2088        })
2089}
2090
2091#[cfg(test)]
2092mod tests {
2093    use super::*;
2094    use crate::manifest::{
2095        BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetSection,
2096        MemberVerificationChecks, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
2097        VerificationPlan,
2098    };
2099    use std::{
2100        env, fs,
2101        path::{Path, PathBuf},
2102        time::{SystemTime, UNIX_EPOCH},
2103    };
2104
2105    const ROOT: &str = "aaaaa-aa";
2106    const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
2107    const CHILD_TWO: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";
2108    const TARGET: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
2109    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
2110
2111    // Build a one-operation ready journal for command preview tests.
2112    fn command_preview_journal(
2113        operation: RestoreApplyOperationKind,
2114        verification_kind: Option<&str>,
2115        verification_method: Option<&str>,
2116    ) -> RestoreApplyJournal {
2117        let journal = RestoreApplyJournal {
2118            journal_version: 1,
2119            backup_id: "fbk_test_001".to_string(),
2120            ready: true,
2121            blocked_reasons: Vec::new(),
2122            operation_count: 1,
2123            pending_operations: 0,
2124            ready_operations: 1,
2125            blocked_operations: 0,
2126            completed_operations: 0,
2127            failed_operations: 0,
2128            operations: vec![RestoreApplyJournalOperation {
2129                sequence: 0,
2130                operation,
2131                state: RestoreApplyOperationState::Ready,
2132                blocking_reasons: Vec::new(),
2133                restore_group: 1,
2134                phase_order: 0,
2135                source_canister: ROOT.to_string(),
2136                target_canister: ROOT.to_string(),
2137                role: "root".to_string(),
2138                snapshot_id: Some("snap-root".to_string()),
2139                artifact_path: Some("artifacts/root".to_string()),
2140                verification_kind: verification_kind.map(str::to_string),
2141                verification_method: verification_method.map(str::to_string),
2142            }],
2143        };
2144
2145        journal.validate().expect("journal should validate");
2146        journal
2147    }
2148
2149    // Build one valid manifest with a parent and child in the same restore group.
2150    fn valid_manifest(identity_mode: IdentityMode) -> FleetBackupManifest {
2151        FleetBackupManifest {
2152            manifest_version: 1,
2153            backup_id: "fbk_test_001".to_string(),
2154            created_at: "2026-04-10T12:00:00Z".to_string(),
2155            tool: ToolMetadata {
2156                name: "canic".to_string(),
2157                version: "v1".to_string(),
2158            },
2159            source: SourceMetadata {
2160                environment: "local".to_string(),
2161                root_canister: ROOT.to_string(),
2162            },
2163            consistency: ConsistencySection {
2164                mode: ConsistencyMode::CrashConsistent,
2165                backup_units: vec![BackupUnit {
2166                    unit_id: "whole-fleet".to_string(),
2167                    kind: BackupUnitKind::WholeFleet,
2168                    roles: vec!["root".to_string(), "app".to_string()],
2169                    consistency_reason: None,
2170                    dependency_closure: Vec::new(),
2171                    topology_validation: "subtree-closed".to_string(),
2172                    quiescence_strategy: None,
2173                }],
2174            },
2175            fleet: FleetSection {
2176                topology_hash_algorithm: "sha256".to_string(),
2177                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
2178                discovery_topology_hash: HASH.to_string(),
2179                pre_snapshot_topology_hash: HASH.to_string(),
2180                topology_hash: HASH.to_string(),
2181                members: vec![
2182                    fleet_member("app", CHILD, Some(ROOT), identity_mode, 1),
2183                    fleet_member("root", ROOT, None, IdentityMode::Fixed, 1),
2184                ],
2185            },
2186            verification: VerificationPlan {
2187                fleet_checks: Vec::new(),
2188                member_checks: Vec::new(),
2189            },
2190        }
2191    }
2192
2193    // Build one manifest member for restore planning tests.
2194    fn fleet_member(
2195        role: &str,
2196        canister_id: &str,
2197        parent_canister_id: Option<&str>,
2198        identity_mode: IdentityMode,
2199        restore_group: u16,
2200    ) -> FleetMember {
2201        FleetMember {
2202            role: role.to_string(),
2203            canister_id: canister_id.to_string(),
2204            parent_canister_id: parent_canister_id.map(str::to_string),
2205            subnet_canister_id: None,
2206            controller_hint: Some(ROOT.to_string()),
2207            identity_mode,
2208            restore_group,
2209            verification_class: "basic".to_string(),
2210            verification_checks: vec![VerificationCheck {
2211                kind: "call".to_string(),
2212                method: Some("canic_ready".to_string()),
2213                roles: Vec::new(),
2214            }],
2215            source_snapshot: SourceSnapshot {
2216                snapshot_id: format!("snap-{role}"),
2217                module_hash: Some(HASH.to_string()),
2218                wasm_hash: Some(HASH.to_string()),
2219                code_version: Some("v0.30.0".to_string()),
2220                artifact_path: format!("artifacts/{role}"),
2221                checksum_algorithm: "sha256".to_string(),
2222                checksum: Some(HASH.to_string()),
2223            },
2224        }
2225    }
2226
2227    // Ensure in-place restore planning sorts parent before child.
2228    #[test]
2229    fn in_place_plan_orders_parent_before_child() {
2230        let manifest = valid_manifest(IdentityMode::Relocatable);
2231
2232        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2233        let ordered = plan.ordered_members();
2234
2235        assert_eq!(plan.backup_id, "fbk_test_001");
2236        assert_eq!(plan.source_environment, "local");
2237        assert_eq!(plan.source_root_canister, ROOT);
2238        assert_eq!(plan.topology_hash, HASH);
2239        assert_eq!(plan.member_count, 2);
2240        assert_eq!(plan.identity_summary.fixed_members, 1);
2241        assert_eq!(plan.identity_summary.relocatable_members, 1);
2242        assert_eq!(plan.identity_summary.in_place_members, 2);
2243        assert_eq!(plan.identity_summary.mapped_members, 0);
2244        assert_eq!(plan.identity_summary.remapped_members, 0);
2245        assert!(plan.verification_summary.verification_required);
2246        assert!(plan.verification_summary.all_members_have_checks);
2247        assert!(plan.readiness_summary.ready);
2248        assert!(plan.readiness_summary.reasons.is_empty());
2249        assert_eq!(plan.verification_summary.fleet_checks, 0);
2250        assert_eq!(plan.verification_summary.member_check_groups, 0);
2251        assert_eq!(plan.verification_summary.member_checks, 2);
2252        assert_eq!(plan.verification_summary.members_with_checks, 2);
2253        assert_eq!(plan.verification_summary.total_checks, 2);
2254        assert_eq!(plan.ordering_summary.phase_count, 1);
2255        assert_eq!(plan.ordering_summary.dependency_free_members, 1);
2256        assert_eq!(plan.ordering_summary.in_group_parent_edges, 1);
2257        assert_eq!(plan.ordering_summary.cross_group_parent_edges, 0);
2258        assert_eq!(ordered[0].phase_order, 0);
2259        assert_eq!(ordered[1].phase_order, 1);
2260        assert_eq!(ordered[0].source_canister, ROOT);
2261        assert_eq!(ordered[1].source_canister, CHILD);
2262        assert_eq!(
2263            ordered[1].ordering_dependency,
2264            Some(RestoreOrderingDependency {
2265                source_canister: ROOT.to_string(),
2266                target_canister: ROOT.to_string(),
2267                relationship: RestoreOrderingRelationship::ParentInSameGroup,
2268            })
2269        );
2270    }
2271
2272    // Ensure cross-group parent dependencies are exposed when the parent phase is earlier.
2273    #[test]
2274    fn plan_reports_parent_dependency_from_earlier_group() {
2275        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2276        manifest.fleet.members[0].restore_group = 2;
2277        manifest.fleet.members[1].restore_group = 1;
2278
2279        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2280        let ordered = plan.ordered_members();
2281
2282        assert_eq!(plan.phases.len(), 2);
2283        assert_eq!(plan.ordering_summary.phase_count, 2);
2284        assert_eq!(plan.ordering_summary.dependency_free_members, 1);
2285        assert_eq!(plan.ordering_summary.in_group_parent_edges, 0);
2286        assert_eq!(plan.ordering_summary.cross_group_parent_edges, 1);
2287        assert_eq!(ordered[0].source_canister, ROOT);
2288        assert_eq!(ordered[1].source_canister, CHILD);
2289        assert_eq!(
2290            ordered[1].ordering_dependency,
2291            Some(RestoreOrderingDependency {
2292                source_canister: ROOT.to_string(),
2293                target_canister: ROOT.to_string(),
2294                relationship: RestoreOrderingRelationship::ParentInEarlierGroup,
2295            })
2296        );
2297    }
2298
2299    // Ensure restore planning fails when groups would restore a child before its parent.
2300    #[test]
2301    fn plan_rejects_parent_in_later_restore_group() {
2302        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2303        manifest.fleet.members[0].restore_group = 1;
2304        manifest.fleet.members[1].restore_group = 2;
2305
2306        let err = RestorePlanner::plan(&manifest, None)
2307            .expect_err("parent-after-child group ordering should fail");
2308
2309        assert!(matches!(
2310            err,
2311            RestorePlanError::ParentRestoreGroupAfterChild { .. }
2312        ));
2313    }
2314
2315    // Ensure fixed identities cannot be remapped.
2316    #[test]
2317    fn fixed_identity_member_cannot_be_remapped() {
2318        let manifest = valid_manifest(IdentityMode::Fixed);
2319        let mapping = RestoreMapping {
2320            members: vec![
2321                RestoreMappingEntry {
2322                    source_canister: ROOT.to_string(),
2323                    target_canister: ROOT.to_string(),
2324                },
2325                RestoreMappingEntry {
2326                    source_canister: CHILD.to_string(),
2327                    target_canister: TARGET.to_string(),
2328                },
2329            ],
2330        };
2331
2332        let err = RestorePlanner::plan(&manifest, Some(&mapping))
2333            .expect_err("fixed member remap should fail");
2334
2335        assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
2336    }
2337
2338    // Ensure relocatable identities may be mapped when all members are covered.
2339    #[test]
2340    fn relocatable_member_can_be_mapped() {
2341        let manifest = valid_manifest(IdentityMode::Relocatable);
2342        let mapping = RestoreMapping {
2343            members: vec![
2344                RestoreMappingEntry {
2345                    source_canister: ROOT.to_string(),
2346                    target_canister: ROOT.to_string(),
2347                },
2348                RestoreMappingEntry {
2349                    source_canister: CHILD.to_string(),
2350                    target_canister: TARGET.to_string(),
2351                },
2352            ],
2353        };
2354
2355        let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
2356        let child = plan
2357            .ordered_members()
2358            .into_iter()
2359            .find(|member| member.source_canister == CHILD)
2360            .expect("child member should be planned");
2361
2362        assert_eq!(plan.identity_summary.fixed_members, 1);
2363        assert_eq!(plan.identity_summary.relocatable_members, 1);
2364        assert_eq!(plan.identity_summary.in_place_members, 1);
2365        assert_eq!(plan.identity_summary.mapped_members, 2);
2366        assert_eq!(plan.identity_summary.remapped_members, 1);
2367        assert_eq!(child.target_canister, TARGET);
2368        assert_eq!(child.parent_target_canister, Some(ROOT.to_string()));
2369    }
2370
2371    // Ensure restore plans carry enough metadata for operator preflight.
2372    #[test]
2373    fn plan_members_include_snapshot_and_verification_metadata() {
2374        let manifest = valid_manifest(IdentityMode::Relocatable);
2375
2376        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2377        let root = plan
2378            .ordered_members()
2379            .into_iter()
2380            .find(|member| member.source_canister == ROOT)
2381            .expect("root member should be planned");
2382
2383        assert_eq!(root.identity_mode, IdentityMode::Fixed);
2384        assert_eq!(root.verification_class, "basic");
2385        assert_eq!(root.verification_checks[0].kind, "call");
2386        assert_eq!(root.source_snapshot.snapshot_id, "snap-root");
2387        assert_eq!(root.source_snapshot.artifact_path, "artifacts/root");
2388    }
2389
2390    // Ensure restore plans make mapping mode explicit.
2391    #[test]
2392    fn plan_includes_mapping_summary() {
2393        let manifest = valid_manifest(IdentityMode::Relocatable);
2394        let in_place = RestorePlanner::plan(&manifest, None).expect("plan should build");
2395
2396        assert!(!in_place.identity_summary.mapping_supplied);
2397        assert!(!in_place.identity_summary.all_sources_mapped);
2398        assert_eq!(in_place.identity_summary.mapped_members, 0);
2399
2400        let mapping = RestoreMapping {
2401            members: vec![
2402                RestoreMappingEntry {
2403                    source_canister: ROOT.to_string(),
2404                    target_canister: ROOT.to_string(),
2405                },
2406                RestoreMappingEntry {
2407                    source_canister: CHILD.to_string(),
2408                    target_canister: TARGET.to_string(),
2409                },
2410            ],
2411        };
2412        let mapped = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
2413
2414        assert!(mapped.identity_summary.mapping_supplied);
2415        assert!(mapped.identity_summary.all_sources_mapped);
2416        assert_eq!(mapped.identity_summary.mapped_members, 2);
2417        assert_eq!(mapped.identity_summary.remapped_members, 1);
2418    }
2419
2420    // Ensure restore plans summarize snapshot provenance completeness.
2421    #[test]
2422    fn plan_includes_snapshot_summary() {
2423        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2424        manifest.fleet.members[1].source_snapshot.module_hash = None;
2425        manifest.fleet.members[1].source_snapshot.wasm_hash = None;
2426        manifest.fleet.members[1].source_snapshot.checksum = None;
2427
2428        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2429
2430        assert!(!plan.snapshot_summary.all_members_have_module_hash);
2431        assert!(!plan.snapshot_summary.all_members_have_wasm_hash);
2432        assert!(plan.snapshot_summary.all_members_have_code_version);
2433        assert!(!plan.snapshot_summary.all_members_have_checksum);
2434        assert_eq!(plan.snapshot_summary.members_with_module_hash, 1);
2435        assert_eq!(plan.snapshot_summary.members_with_wasm_hash, 1);
2436        assert_eq!(plan.snapshot_summary.members_with_code_version, 2);
2437        assert_eq!(plan.snapshot_summary.members_with_checksum, 1);
2438        assert!(!plan.readiness_summary.ready);
2439        assert_eq!(
2440            plan.readiness_summary.reasons,
2441            [
2442                "missing-module-hash",
2443                "missing-wasm-hash",
2444                "missing-snapshot-checksum"
2445            ]
2446        );
2447    }
2448
2449    // Ensure restore plans summarize manifest-level verification work.
2450    #[test]
2451    fn plan_includes_verification_summary() {
2452        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2453        manifest.verification.fleet_checks.push(VerificationCheck {
2454            kind: "fleet-ready".to_string(),
2455            method: None,
2456            roles: Vec::new(),
2457        });
2458        manifest
2459            .verification
2460            .member_checks
2461            .push(MemberVerificationChecks {
2462                role: "app".to_string(),
2463                checks: vec![VerificationCheck {
2464                    kind: "app-ready".to_string(),
2465                    method: Some("ready".to_string()),
2466                    roles: Vec::new(),
2467                }],
2468            });
2469
2470        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2471
2472        assert!(plan.verification_summary.verification_required);
2473        assert!(plan.verification_summary.all_members_have_checks);
2474        assert_eq!(plan.verification_summary.fleet_checks, 1);
2475        assert_eq!(plan.verification_summary.member_check_groups, 1);
2476        assert_eq!(plan.verification_summary.member_checks, 3);
2477        assert_eq!(plan.verification_summary.members_with_checks, 2);
2478        assert_eq!(plan.verification_summary.total_checks, 4);
2479    }
2480
2481    // Ensure restore plans summarize the concrete operation counts automation will schedule.
2482    #[test]
2483    fn plan_includes_operation_summary() {
2484        let manifest = valid_manifest(IdentityMode::Relocatable);
2485
2486        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2487
2488        assert_eq!(plan.operation_summary.planned_snapshot_loads, 2);
2489        assert_eq!(plan.operation_summary.planned_code_reinstalls, 2);
2490        assert_eq!(plan.operation_summary.planned_verification_checks, 2);
2491        assert_eq!(plan.operation_summary.planned_phases, 1);
2492    }
2493
2494    // Ensure initial restore status mirrors the no-mutation restore plan.
2495    #[test]
2496    fn restore_status_starts_all_members_as_planned() {
2497        let manifest = valid_manifest(IdentityMode::Relocatable);
2498
2499        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2500        let status = RestoreStatus::from_plan(&plan);
2501
2502        assert_eq!(status.status_version, 1);
2503        assert_eq!(status.backup_id.as_str(), plan.backup_id.as_str());
2504        assert_eq!(
2505            status.source_environment.as_str(),
2506            plan.source_environment.as_str()
2507        );
2508        assert_eq!(
2509            status.source_root_canister.as_str(),
2510            plan.source_root_canister.as_str()
2511        );
2512        assert_eq!(status.topology_hash.as_str(), plan.topology_hash.as_str());
2513        assert!(status.ready);
2514        assert!(status.readiness_reasons.is_empty());
2515        assert!(status.verification_required);
2516        assert_eq!(status.member_count, 2);
2517        assert_eq!(status.phase_count, 1);
2518        assert_eq!(status.planned_snapshot_loads, 2);
2519        assert_eq!(status.planned_code_reinstalls, 2);
2520        assert_eq!(status.planned_verification_checks, 2);
2521        assert_eq!(status.phases.len(), 1);
2522        assert_eq!(status.phases[0].restore_group, 1);
2523        assert_eq!(status.phases[0].members.len(), 2);
2524        assert_eq!(
2525            status.phases[0].members[0].state,
2526            RestoreMemberState::Planned
2527        );
2528        assert_eq!(status.phases[0].members[0].source_canister, ROOT);
2529        assert_eq!(status.phases[0].members[0].target_canister, ROOT);
2530        assert_eq!(status.phases[0].members[0].snapshot_id, "snap-root");
2531        assert_eq!(status.phases[0].members[0].artifact_path, "artifacts/root");
2532        assert_eq!(
2533            status.phases[0].members[1].state,
2534            RestoreMemberState::Planned
2535        );
2536        assert_eq!(status.phases[0].members[1].source_canister, CHILD);
2537    }
2538
2539    // Ensure apply dry-runs render ordered operations without mutating targets.
2540    #[test]
2541    fn apply_dry_run_renders_ordered_member_operations() {
2542        let manifest = valid_manifest(IdentityMode::Relocatable);
2543
2544        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2545        let status = RestoreStatus::from_plan(&plan);
2546        let dry_run =
2547            RestoreApplyDryRun::try_from_plan(&plan, Some(&status)).expect("dry-run should build");
2548
2549        assert_eq!(dry_run.dry_run_version, 1);
2550        assert_eq!(dry_run.backup_id.as_str(), "fbk_test_001");
2551        assert!(dry_run.ready);
2552        assert!(dry_run.status_supplied);
2553        assert_eq!(dry_run.member_count, 2);
2554        assert_eq!(dry_run.phase_count, 1);
2555        assert_eq!(dry_run.planned_snapshot_loads, 2);
2556        assert_eq!(dry_run.planned_code_reinstalls, 2);
2557        assert_eq!(dry_run.planned_verification_checks, 2);
2558        assert_eq!(dry_run.rendered_operations, 8);
2559        assert_eq!(dry_run.phases.len(), 1);
2560
2561        let operations = &dry_run.phases[0].operations;
2562        assert_eq!(operations[0].sequence, 0);
2563        assert_eq!(
2564            operations[0].operation,
2565            RestoreApplyOperationKind::UploadSnapshot
2566        );
2567        assert_eq!(operations[0].source_canister, ROOT);
2568        assert_eq!(operations[0].target_canister, ROOT);
2569        assert_eq!(operations[0].snapshot_id, Some("snap-root".to_string()));
2570        assert_eq!(
2571            operations[0].artifact_path,
2572            Some("artifacts/root".to_string())
2573        );
2574        assert_eq!(
2575            operations[1].operation,
2576            RestoreApplyOperationKind::LoadSnapshot
2577        );
2578        assert_eq!(
2579            operations[2].operation,
2580            RestoreApplyOperationKind::ReinstallCode
2581        );
2582        assert_eq!(
2583            operations[3].operation,
2584            RestoreApplyOperationKind::VerifyMember
2585        );
2586        assert_eq!(operations[3].verification_kind, Some("call".to_string()));
2587        assert_eq!(
2588            operations[3].verification_method,
2589            Some("canic_ready".to_string())
2590        );
2591        assert_eq!(operations[4].source_canister, CHILD);
2592        assert_eq!(
2593            operations[7].operation,
2594            RestoreApplyOperationKind::VerifyMember
2595        );
2596    }
2597
2598    // Ensure apply dry-run operation sequences remain unique across phases.
2599    #[test]
2600    fn apply_dry_run_sequences_operations_across_phases() {
2601        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2602        manifest.fleet.members[0].restore_group = 2;
2603
2604        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2605        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
2606
2607        assert_eq!(dry_run.phases.len(), 2);
2608        assert_eq!(dry_run.rendered_operations, 8);
2609        assert_eq!(dry_run.phases[0].operations[0].sequence, 0);
2610        assert_eq!(dry_run.phases[0].operations[3].sequence, 3);
2611        assert_eq!(dry_run.phases[1].operations[0].sequence, 4);
2612        assert_eq!(dry_run.phases[1].operations[3].sequence, 7);
2613    }
2614
2615    // Ensure apply dry-runs can prove referenced artifacts exist and match checksums.
2616    #[test]
2617    fn apply_dry_run_validates_artifacts_under_backup_root() {
2618        let root = temp_dir("canic-restore-apply-artifacts-ok");
2619        fs::create_dir_all(&root).expect("create temp root");
2620        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2621        set_member_artifact(
2622            &mut manifest,
2623            CHILD,
2624            &root,
2625            "artifacts/child",
2626            b"child-snapshot",
2627        );
2628        set_member_artifact(
2629            &mut manifest,
2630            ROOT,
2631            &root,
2632            "artifacts/root",
2633            b"root-snapshot",
2634        );
2635
2636        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2637        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
2638            .expect("dry-run should validate artifacts");
2639
2640        let validation = dry_run
2641            .artifact_validation
2642            .expect("artifact validation should be present");
2643        assert_eq!(validation.checked_members, 2);
2644        assert!(validation.artifacts_present);
2645        assert!(validation.checksums_verified);
2646        assert_eq!(validation.members_with_expected_checksums, 2);
2647        assert_eq!(validation.checks[0].source_canister, ROOT);
2648        assert!(validation.checks[0].checksum_verified);
2649
2650        fs::remove_dir_all(root).expect("remove temp root");
2651    }
2652
2653    // Ensure an artifact-validated apply dry-run produces a ready initial journal.
2654    #[test]
2655    fn apply_journal_marks_validated_operations_ready() {
2656        let root = temp_dir("canic-restore-apply-journal-ready");
2657        fs::create_dir_all(&root).expect("create temp root");
2658        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2659        set_member_artifact(
2660            &mut manifest,
2661            CHILD,
2662            &root,
2663            "artifacts/child",
2664            b"child-snapshot",
2665        );
2666        set_member_artifact(
2667            &mut manifest,
2668            ROOT,
2669            &root,
2670            "artifacts/root",
2671            b"root-snapshot",
2672        );
2673
2674        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2675        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
2676            .expect("dry-run should validate artifacts");
2677        let journal = RestoreApplyJournal::from_dry_run(&dry_run);
2678
2679        fs::remove_dir_all(root).expect("remove temp root");
2680        assert_eq!(journal.journal_version, 1);
2681        assert_eq!(journal.backup_id.as_str(), "fbk_test_001");
2682        assert!(journal.ready);
2683        assert!(journal.blocked_reasons.is_empty());
2684        assert_eq!(journal.operation_count, 8);
2685        assert_eq!(journal.ready_operations, 8);
2686        assert_eq!(journal.blocked_operations, 0);
2687        assert_eq!(journal.operations[0].sequence, 0);
2688        assert_eq!(
2689            journal.operations[0].state,
2690            RestoreApplyOperationState::Ready
2691        );
2692        assert!(journal.operations[0].blocking_reasons.is_empty());
2693    }
2694
2695    // Ensure apply journals block when artifact validation was not supplied.
2696    #[test]
2697    fn apply_journal_blocks_without_artifact_validation() {
2698        let manifest = valid_manifest(IdentityMode::Relocatable);
2699
2700        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2701        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
2702        let journal = RestoreApplyJournal::from_dry_run(&dry_run);
2703
2704        assert!(!journal.ready);
2705        assert_eq!(journal.ready_operations, 0);
2706        assert_eq!(journal.blocked_operations, 8);
2707        assert!(
2708            journal
2709                .blocked_reasons
2710                .contains(&"missing-artifact-validation".to_string())
2711        );
2712        assert!(
2713            journal.operations[0]
2714                .blocking_reasons
2715                .contains(&"missing-artifact-validation".to_string())
2716        );
2717    }
2718
2719    // Ensure apply journal status exposes compact readiness and next-operation state.
2720    #[test]
2721    fn apply_journal_status_reports_next_ready_operation() {
2722        let root = temp_dir("canic-restore-apply-journal-status");
2723        fs::create_dir_all(&root).expect("create temp root");
2724        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2725        set_member_artifact(
2726            &mut manifest,
2727            CHILD,
2728            &root,
2729            "artifacts/child",
2730            b"child-snapshot",
2731        );
2732        set_member_artifact(
2733            &mut manifest,
2734            ROOT,
2735            &root,
2736            "artifacts/root",
2737            b"root-snapshot",
2738        );
2739
2740        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2741        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
2742            .expect("dry-run should validate artifacts");
2743        let journal = RestoreApplyJournal::from_dry_run(&dry_run);
2744        let status = journal.status();
2745
2746        fs::remove_dir_all(root).expect("remove temp root");
2747        assert_eq!(status.status_version, 1);
2748        assert_eq!(status.backup_id.as_str(), "fbk_test_001");
2749        assert!(status.ready);
2750        assert!(!status.complete);
2751        assert_eq!(status.operation_count, 8);
2752        assert_eq!(status.ready_operations, 8);
2753        assert_eq!(status.next_ready_sequence, Some(0));
2754        assert_eq!(
2755            status.next_ready_operation,
2756            Some(RestoreApplyOperationKind::UploadSnapshot)
2757        );
2758    }
2759
2760    // Ensure next-operation output exposes the full next ready journal row.
2761    #[test]
2762    fn apply_journal_next_operation_reports_full_ready_row() {
2763        let root = temp_dir("canic-restore-apply-journal-next");
2764        fs::create_dir_all(&root).expect("create temp root");
2765        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2766        set_member_artifact(
2767            &mut manifest,
2768            CHILD,
2769            &root,
2770            "artifacts/child",
2771            b"child-snapshot",
2772        );
2773        set_member_artifact(
2774            &mut manifest,
2775            ROOT,
2776            &root,
2777            "artifacts/root",
2778            b"root-snapshot",
2779        );
2780
2781        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2782        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
2783            .expect("dry-run should validate artifacts");
2784        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
2785        journal
2786            .mark_operation_completed(0)
2787            .expect("mark operation completed");
2788        let next = journal.next_operation();
2789
2790        fs::remove_dir_all(root).expect("remove temp root");
2791        assert!(next.ready);
2792        assert!(!next.complete);
2793        assert!(next.operation_available);
2794        let operation = next.operation.expect("next operation");
2795        assert_eq!(operation.sequence, 1);
2796        assert_eq!(operation.state, RestoreApplyOperationState::Ready);
2797        assert_eq!(operation.operation, RestoreApplyOperationKind::LoadSnapshot);
2798        assert_eq!(operation.source_canister, ROOT);
2799    }
2800
2801    // Ensure blocked journals report no next ready operation.
2802    #[test]
2803    fn apply_journal_next_operation_reports_blocked_state() {
2804        let manifest = valid_manifest(IdentityMode::Relocatable);
2805
2806        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2807        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
2808        let journal = RestoreApplyJournal::from_dry_run(&dry_run);
2809        let next = journal.next_operation();
2810
2811        assert!(!next.ready);
2812        assert!(!next.operation_available);
2813        assert!(next.operation.is_none());
2814        assert!(
2815            next.blocked_reasons
2816                .contains(&"missing-artifact-validation".to_string())
2817        );
2818    }
2819
2820    // Ensure command previews expose the dfx upload command without executing it.
2821    #[test]
2822    fn apply_journal_command_preview_reports_upload_command() {
2823        let root = temp_dir("canic-restore-apply-command-upload");
2824        fs::create_dir_all(&root).expect("create temp root");
2825        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2826        set_member_artifact(
2827            &mut manifest,
2828            CHILD,
2829            &root,
2830            "artifacts/child",
2831            b"child-snapshot",
2832        );
2833        set_member_artifact(
2834            &mut manifest,
2835            ROOT,
2836            &root,
2837            "artifacts/root",
2838            b"root-snapshot",
2839        );
2840
2841        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2842        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
2843            .expect("dry-run should validate artifacts");
2844        let journal = RestoreApplyJournal::from_dry_run(&dry_run);
2845        let preview = journal.next_command_preview();
2846
2847        fs::remove_dir_all(root).expect("remove temp root");
2848        assert!(preview.ready);
2849        assert!(preview.operation_available);
2850        assert!(preview.command_available);
2851        let command = preview.command.expect("command preview");
2852        assert_eq!(command.program, "dfx");
2853        assert_eq!(
2854            command.args,
2855            vec![
2856                "canister".to_string(),
2857                "snapshot".to_string(),
2858                "upload".to_string(),
2859                "--dir".to_string(),
2860                "artifacts/root".to_string(),
2861                ROOT.to_string(),
2862            ]
2863        );
2864        assert!(command.mutates);
2865        assert!(!command.requires_stopped_canister);
2866    }
2867
2868    // Ensure command previews carry configured dfx program and network.
2869    #[test]
2870    fn apply_journal_command_preview_honors_command_config() {
2871        let root = temp_dir("canic-restore-apply-command-config");
2872        fs::create_dir_all(&root).expect("create temp root");
2873        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2874        set_member_artifact(
2875            &mut manifest,
2876            CHILD,
2877            &root,
2878            "artifacts/child",
2879            b"child-snapshot",
2880        );
2881        set_member_artifact(
2882            &mut manifest,
2883            ROOT,
2884            &root,
2885            "artifacts/root",
2886            b"root-snapshot",
2887        );
2888
2889        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2890        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
2891            .expect("dry-run should validate artifacts");
2892        let journal = RestoreApplyJournal::from_dry_run(&dry_run);
2893        let preview = journal.next_command_preview_with_config(&RestoreApplyCommandConfig {
2894            program: "/tmp/dfx".to_string(),
2895            network: Some("local".to_string()),
2896        });
2897
2898        fs::remove_dir_all(root).expect("remove temp root");
2899        let command = preview.command.expect("command preview");
2900        assert_eq!(command.program, "/tmp/dfx");
2901        assert_eq!(
2902            command.args,
2903            vec![
2904                "canister".to_string(),
2905                "--network".to_string(),
2906                "local".to_string(),
2907                "snapshot".to_string(),
2908                "upload".to_string(),
2909                "--dir".to_string(),
2910                "artifacts/root".to_string(),
2911                ROOT.to_string(),
2912            ]
2913        );
2914    }
2915
2916    // Ensure command previews expose stopped-canister hints for snapshot load.
2917    #[test]
2918    fn apply_journal_command_preview_reports_load_command() {
2919        let root = temp_dir("canic-restore-apply-command-load");
2920        fs::create_dir_all(&root).expect("create temp root");
2921        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2922        set_member_artifact(
2923            &mut manifest,
2924            CHILD,
2925            &root,
2926            "artifacts/child",
2927            b"child-snapshot",
2928        );
2929        set_member_artifact(
2930            &mut manifest,
2931            ROOT,
2932            &root,
2933            "artifacts/root",
2934            b"root-snapshot",
2935        );
2936
2937        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2938        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
2939            .expect("dry-run should validate artifacts");
2940        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
2941        journal
2942            .mark_operation_completed(0)
2943            .expect("mark upload completed");
2944        let preview = journal.next_command_preview();
2945
2946        fs::remove_dir_all(root).expect("remove temp root");
2947        let command = preview.command.expect("command preview");
2948        assert_eq!(
2949            command.args,
2950            vec![
2951                "canister".to_string(),
2952                "snapshot".to_string(),
2953                "load".to_string(),
2954                ROOT.to_string(),
2955                "snap-root".to_string(),
2956            ]
2957        );
2958        assert!(command.mutates);
2959        assert!(command.requires_stopped_canister);
2960    }
2961
2962    // Ensure command previews expose reinstall commands without executing them.
2963    #[test]
2964    fn apply_journal_command_preview_reports_reinstall_command() {
2965        let journal = command_preview_journal(RestoreApplyOperationKind::ReinstallCode, None, None);
2966        let preview = journal.next_command_preview_with_config(&RestoreApplyCommandConfig {
2967            program: "dfx".to_string(),
2968            network: Some("local".to_string()),
2969        });
2970
2971        assert!(preview.command_available);
2972        let command = preview.command.expect("command preview");
2973        assert_eq!(
2974            command.args,
2975            vec![
2976                "canister".to_string(),
2977                "--network".to_string(),
2978                "local".to_string(),
2979                "install".to_string(),
2980                "--mode".to_string(),
2981                "reinstall".to_string(),
2982                "--yes".to_string(),
2983                ROOT.to_string(),
2984            ]
2985        );
2986        assert!(command.mutates);
2987        assert!(!command.requires_stopped_canister);
2988    }
2989
2990    // Ensure status verification previews use `dfx canister status`.
2991    #[test]
2992    fn apply_journal_command_preview_reports_status_verification_command() {
2993        let journal = command_preview_journal(
2994            RestoreApplyOperationKind::VerifyMember,
2995            Some("status"),
2996            None,
2997        );
2998        let preview = journal.next_command_preview();
2999
3000        assert!(preview.command_available);
3001        let command = preview.command.expect("command preview");
3002        assert_eq!(
3003            command.args,
3004            vec![
3005                "canister".to_string(),
3006                "status".to_string(),
3007                ROOT.to_string()
3008            ]
3009        );
3010        assert!(!command.mutates);
3011        assert!(!command.requires_stopped_canister);
3012    }
3013
3014    // Ensure method verification previews use `dfx canister call`.
3015    #[test]
3016    fn apply_journal_command_preview_reports_method_verification_command() {
3017        let journal = command_preview_journal(
3018            RestoreApplyOperationKind::VerifyMember,
3019            Some("query"),
3020            Some("health"),
3021        );
3022        let preview = journal.next_command_preview();
3023
3024        assert!(preview.command_available);
3025        let command = preview.command.expect("command preview");
3026        assert_eq!(
3027            command.args,
3028            vec![
3029                "canister".to_string(),
3030                "call".to_string(),
3031                ROOT.to_string(),
3032                "health".to_string(),
3033            ]
3034        );
3035        assert!(!command.mutates);
3036        assert!(!command.requires_stopped_canister);
3037    }
3038
3039    // Ensure unsupported verification rows do not pretend to be runnable.
3040    #[test]
3041    fn apply_journal_command_preview_reports_unavailable_for_unknown_verification() {
3042        let journal =
3043            command_preview_journal(RestoreApplyOperationKind::VerifyMember, Some("query"), None);
3044        let preview = journal.next_command_preview();
3045
3046        assert!(preview.operation_available);
3047        assert!(!preview.command_available);
3048        assert!(preview.command.is_none());
3049    }
3050
3051    // Ensure apply journal validation rejects inconsistent state counts.
3052    #[test]
3053    fn apply_journal_validation_rejects_count_mismatch() {
3054        let manifest = valid_manifest(IdentityMode::Relocatable);
3055
3056        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3057        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3058        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3059        journal.blocked_operations = 0;
3060
3061        let err = journal.validate().expect_err("count mismatch should fail");
3062
3063        assert!(matches!(
3064            err,
3065            RestoreApplyJournalError::CountMismatch {
3066                field: "blocked_operations",
3067                ..
3068            }
3069        ));
3070    }
3071
3072    // Ensure apply journal validation rejects duplicate operation sequences.
3073    #[test]
3074    fn apply_journal_validation_rejects_duplicate_sequences() {
3075        let manifest = valid_manifest(IdentityMode::Relocatable);
3076
3077        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3078        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3079        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3080        journal.operations[1].sequence = journal.operations[0].sequence;
3081
3082        let err = journal
3083            .validate()
3084            .expect_err("duplicate sequence should fail");
3085
3086        assert!(matches!(
3087            err,
3088            RestoreApplyJournalError::DuplicateSequence(0)
3089        ));
3090    }
3091
3092    // Ensure failed journal operations must explain why execution failed.
3093    #[test]
3094    fn apply_journal_validation_rejects_failed_without_reason() {
3095        let manifest = valid_manifest(IdentityMode::Relocatable);
3096
3097        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3098        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3099        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3100        journal.operations[0].state = RestoreApplyOperationState::Failed;
3101        journal.operations[0].blocking_reasons = Vec::new();
3102        journal.blocked_operations -= 1;
3103        journal.failed_operations = 1;
3104
3105        let err = journal
3106            .validate()
3107            .expect_err("failed operation without reason should fail");
3108
3109        assert!(matches!(
3110            err,
3111            RestoreApplyJournalError::FailureReasonRequired(0)
3112        ));
3113    }
3114
3115    // Ensure completing a journal operation updates counts and advances status.
3116    #[test]
3117    fn apply_journal_mark_completed_advances_next_ready_operation() {
3118        let root = temp_dir("canic-restore-apply-journal-completed");
3119        fs::create_dir_all(&root).expect("create temp root");
3120        let mut manifest = valid_manifest(IdentityMode::Relocatable);
3121        set_member_artifact(
3122            &mut manifest,
3123            CHILD,
3124            &root,
3125            "artifacts/child",
3126            b"child-snapshot",
3127        );
3128        set_member_artifact(
3129            &mut manifest,
3130            ROOT,
3131            &root,
3132            "artifacts/root",
3133            b"root-snapshot",
3134        );
3135
3136        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3137        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3138            .expect("dry-run should validate artifacts");
3139        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3140
3141        journal
3142            .mark_operation_completed(0)
3143            .expect("mark operation completed");
3144        let status = journal.status();
3145
3146        fs::remove_dir_all(root).expect("remove temp root");
3147        assert_eq!(
3148            journal.operations[0].state,
3149            RestoreApplyOperationState::Completed
3150        );
3151        assert_eq!(journal.completed_operations, 1);
3152        assert_eq!(journal.ready_operations, 7);
3153        assert_eq!(status.next_ready_sequence, Some(1));
3154    }
3155
3156    // Ensure journal transitions cannot skip earlier ready operations.
3157    #[test]
3158    fn apply_journal_mark_completed_rejects_out_of_order_operation() {
3159        let root = temp_dir("canic-restore-apply-journal-out-of-order");
3160        fs::create_dir_all(&root).expect("create temp root");
3161        let mut manifest = valid_manifest(IdentityMode::Relocatable);
3162        set_member_artifact(
3163            &mut manifest,
3164            CHILD,
3165            &root,
3166            "artifacts/child",
3167            b"child-snapshot",
3168        );
3169        set_member_artifact(
3170            &mut manifest,
3171            ROOT,
3172            &root,
3173            "artifacts/root",
3174            b"root-snapshot",
3175        );
3176
3177        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3178        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3179            .expect("dry-run should validate artifacts");
3180        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3181
3182        let err = journal
3183            .mark_operation_completed(1)
3184            .expect_err("out-of-order operation should fail");
3185
3186        fs::remove_dir_all(root).expect("remove temp root");
3187        assert!(matches!(
3188            err,
3189            RestoreApplyJournalError::OutOfOrderOperationTransition {
3190                requested: 1,
3191                next: 0
3192            }
3193        ));
3194        assert_eq!(journal.completed_operations, 0);
3195        assert_eq!(journal.ready_operations, 8);
3196    }
3197
3198    // Ensure failed journal operations carry a reason and update counts.
3199    #[test]
3200    fn apply_journal_mark_failed_records_reason() {
3201        let root = temp_dir("canic-restore-apply-journal-failed");
3202        fs::create_dir_all(&root).expect("create temp root");
3203        let mut manifest = valid_manifest(IdentityMode::Relocatable);
3204        set_member_artifact(
3205            &mut manifest,
3206            CHILD,
3207            &root,
3208            "artifacts/child",
3209            b"child-snapshot",
3210        );
3211        set_member_artifact(
3212            &mut manifest,
3213            ROOT,
3214            &root,
3215            "artifacts/root",
3216            b"root-snapshot",
3217        );
3218
3219        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3220        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3221            .expect("dry-run should validate artifacts");
3222        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3223
3224        journal
3225            .mark_operation_failed(0, "dfx-load-failed".to_string())
3226            .expect("mark operation failed");
3227
3228        fs::remove_dir_all(root).expect("remove temp root");
3229        assert_eq!(
3230            journal.operations[0].state,
3231            RestoreApplyOperationState::Failed
3232        );
3233        assert_eq!(
3234            journal.operations[0].blocking_reasons,
3235            vec!["dfx-load-failed".to_string()]
3236        );
3237        assert_eq!(journal.failed_operations, 1);
3238        assert_eq!(journal.ready_operations, 7);
3239    }
3240
3241    // Ensure blocked operations cannot be manually completed before blockers clear.
3242    #[test]
3243    fn apply_journal_rejects_blocked_operation_completion() {
3244        let manifest = valid_manifest(IdentityMode::Relocatable);
3245
3246        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3247        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3248        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3249
3250        let err = journal
3251            .mark_operation_completed(0)
3252            .expect_err("blocked operation should not complete");
3253
3254        assert!(matches!(
3255            err,
3256            RestoreApplyJournalError::InvalidOperationTransition { sequence: 0, .. }
3257        ));
3258    }
3259
3260    // Ensure apply dry-runs fail closed when a referenced artifact is missing.
3261    #[test]
3262    fn apply_dry_run_rejects_missing_artifacts() {
3263        let root = temp_dir("canic-restore-apply-artifacts-missing");
3264        fs::create_dir_all(&root).expect("create temp root");
3265        let mut manifest = valid_manifest(IdentityMode::Relocatable);
3266        manifest.fleet.members[0].source_snapshot.artifact_path = "missing-child".to_string();
3267
3268        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3269        let err = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3270            .expect_err("missing artifact should fail");
3271
3272        fs::remove_dir_all(root).expect("remove temp root");
3273        assert!(matches!(
3274            err,
3275            RestoreApplyDryRunError::ArtifactMissing { .. }
3276        ));
3277    }
3278
3279    // Ensure apply dry-runs reject artifact paths that escape the backup directory.
3280    #[test]
3281    fn apply_dry_run_rejects_artifact_path_traversal() {
3282        let root = temp_dir("canic-restore-apply-artifacts-traversal");
3283        fs::create_dir_all(&root).expect("create temp root");
3284        let mut manifest = valid_manifest(IdentityMode::Relocatable);
3285        manifest.fleet.members[1].source_snapshot.artifact_path = "../outside".to_string();
3286
3287        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3288        let err = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3289            .expect_err("path traversal should fail");
3290
3291        fs::remove_dir_all(root).expect("remove temp root");
3292        assert!(matches!(
3293            err,
3294            RestoreApplyDryRunError::ArtifactPathEscapesBackup { .. }
3295        ));
3296    }
3297
3298    // Ensure apply dry-runs reject status files that do not match the plan.
3299    #[test]
3300    fn apply_dry_run_rejects_mismatched_status() {
3301        let manifest = valid_manifest(IdentityMode::Relocatable);
3302
3303        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3304        let mut status = RestoreStatus::from_plan(&plan);
3305        status.backup_id = "other-backup".to_string();
3306
3307        let err = RestoreApplyDryRun::try_from_plan(&plan, Some(&status))
3308            .expect_err("mismatched status should fail");
3309
3310        assert!(matches!(
3311            err,
3312            RestoreApplyDryRunError::StatusPlanMismatch {
3313                field: "backup_id",
3314                ..
3315            }
3316        ));
3317    }
3318
3319    // Ensure role-level verification checks are counted once per matching member.
3320    #[test]
3321    fn plan_expands_role_verification_checks_per_matching_member() {
3322        let mut manifest = valid_manifest(IdentityMode::Relocatable);
3323        manifest.fleet.members.push(fleet_member(
3324            "app",
3325            CHILD_TWO,
3326            Some(ROOT),
3327            IdentityMode::Relocatable,
3328            1,
3329        ));
3330        manifest
3331            .verification
3332            .member_checks
3333            .push(MemberVerificationChecks {
3334                role: "app".to_string(),
3335                checks: vec![VerificationCheck {
3336                    kind: "app-ready".to_string(),
3337                    method: Some("ready".to_string()),
3338                    roles: Vec::new(),
3339                }],
3340            });
3341
3342        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3343
3344        assert_eq!(plan.verification_summary.fleet_checks, 0);
3345        assert_eq!(plan.verification_summary.member_check_groups, 1);
3346        assert_eq!(plan.verification_summary.member_checks, 5);
3347        assert_eq!(plan.verification_summary.members_with_checks, 3);
3348        assert_eq!(plan.verification_summary.total_checks, 5);
3349    }
3350
3351    // Ensure mapped restores must cover every source member.
3352    #[test]
3353    fn mapped_restore_requires_complete_mapping() {
3354        let manifest = valid_manifest(IdentityMode::Relocatable);
3355        let mapping = RestoreMapping {
3356            members: vec![RestoreMappingEntry {
3357                source_canister: ROOT.to_string(),
3358                target_canister: ROOT.to_string(),
3359            }],
3360        };
3361
3362        let err = RestorePlanner::plan(&manifest, Some(&mapping))
3363            .expect_err("incomplete mapping should fail");
3364
3365        assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
3366    }
3367
3368    // Ensure mappings cannot silently include canisters outside the manifest.
3369    #[test]
3370    fn mapped_restore_rejects_unknown_mapping_sources() {
3371        let manifest = valid_manifest(IdentityMode::Relocatable);
3372        let unknown = "rdmx6-jaaaa-aaaaa-aaadq-cai";
3373        let mapping = RestoreMapping {
3374            members: vec![
3375                RestoreMappingEntry {
3376                    source_canister: ROOT.to_string(),
3377                    target_canister: ROOT.to_string(),
3378                },
3379                RestoreMappingEntry {
3380                    source_canister: CHILD.to_string(),
3381                    target_canister: TARGET.to_string(),
3382                },
3383                RestoreMappingEntry {
3384                    source_canister: unknown.to_string(),
3385                    target_canister: unknown.to_string(),
3386                },
3387            ],
3388        };
3389
3390        let err = RestorePlanner::plan(&manifest, Some(&mapping))
3391            .expect_err("unknown mapping source should fail");
3392
3393        assert!(matches!(err, RestorePlanError::UnknownMappingSource(_)));
3394    }
3395
3396    // Ensure duplicate target mappings fail before a plan is produced.
3397    #[test]
3398    fn duplicate_mapping_targets_fail_validation() {
3399        let manifest = valid_manifest(IdentityMode::Relocatable);
3400        let mapping = RestoreMapping {
3401            members: vec![
3402                RestoreMappingEntry {
3403                    source_canister: ROOT.to_string(),
3404                    target_canister: ROOT.to_string(),
3405                },
3406                RestoreMappingEntry {
3407                    source_canister: CHILD.to_string(),
3408                    target_canister: ROOT.to_string(),
3409                },
3410            ],
3411        };
3412
3413        let err = RestorePlanner::plan(&manifest, Some(&mapping))
3414            .expect_err("duplicate targets should fail");
3415
3416        assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
3417    }
3418
3419    // Write one artifact and record its path and checksum in the test manifest.
3420    fn set_member_artifact(
3421        manifest: &mut FleetBackupManifest,
3422        canister_id: &str,
3423        root: &Path,
3424        artifact_path: &str,
3425        bytes: &[u8],
3426    ) {
3427        let full_path = root.join(artifact_path);
3428        fs::create_dir_all(full_path.parent().expect("artifact parent")).expect("create parent");
3429        fs::write(&full_path, bytes).expect("write artifact");
3430        let checksum = ArtifactChecksum::from_bytes(bytes);
3431        let member = manifest
3432            .fleet
3433            .members
3434            .iter_mut()
3435            .find(|member| member.canister_id == canister_id)
3436            .expect("member should exist");
3437        member.source_snapshot.artifact_path = artifact_path.to_string();
3438        member.source_snapshot.checksum = Some(checksum.hash);
3439    }
3440
3441    // Return a unique temporary directory for restore tests.
3442    fn temp_dir(name: &str) -> PathBuf {
3443        let nanos = SystemTime::now()
3444            .duration_since(UNIX_EPOCH)
3445            .expect("system time should be after epoch")
3446            .as_nanos();
3447        env::temp_dir().join(format!("{name}-{nanos}"))
3448    }
3449}