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