Skip to main content

canic_backup/restore/
mod.rs

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