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