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