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