Skip to main content

canic_backup/restore/
mod.rs

1use crate::{
2    artifacts::{ArtifactChecksum, ArtifactChecksumError},
3    manifest::{
4        FleetBackupManifest, FleetMember, IdentityMode, ManifestDesignConformanceReport,
5        ManifestValidationError, SourceSnapshot, 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 design_conformance: Option<ManifestDesignConformanceReport>,
65    #[serde(default)]
66    pub fleet_verification_checks: Vec<VerificationCheck>,
67    pub phases: Vec<RestorePhase>,
68}
69
70impl RestorePlan {
71    /// Return all planned members in execution order.
72    #[must_use]
73    pub fn ordered_members(&self) -> Vec<&RestorePlanMember> {
74        self.phases
75            .iter()
76            .flat_map(|phase| phase.members.iter())
77            .collect()
78    }
79}
80
81///
82/// RestoreStatus
83///
84
85#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
86pub struct RestoreStatus {
87    pub status_version: u16,
88    pub backup_id: String,
89    pub source_environment: String,
90    pub source_root_canister: String,
91    pub topology_hash: String,
92    pub ready: bool,
93    pub readiness_reasons: Vec<String>,
94    pub verification_required: bool,
95    pub member_count: usize,
96    pub phase_count: usize,
97    #[serde(default)]
98    pub planned_snapshot_uploads: usize,
99    pub planned_snapshot_loads: usize,
100    pub planned_code_reinstalls: usize,
101    pub planned_verification_checks: usize,
102    #[serde(default)]
103    pub planned_operations: usize,
104    pub phases: Vec<RestoreStatusPhase>,
105}
106
107impl RestoreStatus {
108    /// Build the initial no-mutation restore status from a computed plan.
109    #[must_use]
110    pub fn from_plan(plan: &RestorePlan) -> Self {
111        Self {
112            status_version: 1,
113            backup_id: plan.backup_id.clone(),
114            source_environment: plan.source_environment.clone(),
115            source_root_canister: plan.source_root_canister.clone(),
116            topology_hash: plan.topology_hash.clone(),
117            ready: plan.readiness_summary.ready,
118            readiness_reasons: plan.readiness_summary.reasons.clone(),
119            verification_required: plan.verification_summary.verification_required,
120            member_count: plan.member_count,
121            phase_count: plan.ordering_summary.phase_count,
122            planned_snapshot_uploads: plan
123                .operation_summary
124                .effective_planned_snapshot_uploads(plan.member_count),
125            planned_snapshot_loads: plan.operation_summary.planned_snapshot_loads,
126            planned_code_reinstalls: plan.operation_summary.planned_code_reinstalls,
127            planned_verification_checks: plan.operation_summary.planned_verification_checks,
128            planned_operations: plan
129                .operation_summary
130                .effective_planned_operations(plan.member_count),
131            phases: plan
132                .phases
133                .iter()
134                .map(RestoreStatusPhase::from_plan_phase)
135                .collect(),
136        }
137    }
138}
139
140///
141/// RestoreStatusPhase
142///
143
144#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
145pub struct RestoreStatusPhase {
146    pub restore_group: u16,
147    pub members: Vec<RestoreStatusMember>,
148}
149
150impl RestoreStatusPhase {
151    // Build one status phase from one planned restore phase.
152    fn from_plan_phase(phase: &RestorePhase) -> Self {
153        Self {
154            restore_group: phase.restore_group,
155            members: phase
156                .members
157                .iter()
158                .map(RestoreStatusMember::from_plan_member)
159                .collect(),
160        }
161    }
162}
163
164///
165/// RestoreStatusMember
166///
167
168#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
169pub struct RestoreStatusMember {
170    pub source_canister: String,
171    pub target_canister: String,
172    pub role: String,
173    pub restore_group: u16,
174    pub phase_order: usize,
175    pub snapshot_id: String,
176    pub artifact_path: String,
177    pub state: RestoreMemberState,
178}
179
180impl RestoreStatusMember {
181    // Build one member status row from one planned restore member.
182    fn from_plan_member(member: &RestorePlanMember) -> Self {
183        Self {
184            source_canister: member.source_canister.clone(),
185            target_canister: member.target_canister.clone(),
186            role: member.role.clone(),
187            restore_group: member.restore_group,
188            phase_order: member.phase_order,
189            snapshot_id: member.source_snapshot.snapshot_id.clone(),
190            artifact_path: member.source_snapshot.artifact_path.clone(),
191            state: RestoreMemberState::Planned,
192        }
193    }
194}
195
196///
197/// RestoreMemberState
198///
199
200#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
201#[serde(rename_all = "kebab-case")]
202pub enum RestoreMemberState {
203    Planned,
204}
205
206///
207/// RestoreApplyDryRun
208///
209
210#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
211pub struct RestoreApplyDryRun {
212    pub dry_run_version: u16,
213    pub backup_id: String,
214    pub ready: bool,
215    pub readiness_reasons: Vec<String>,
216    pub member_count: usize,
217    pub phase_count: usize,
218    pub status_supplied: bool,
219    #[serde(default)]
220    pub planned_snapshot_uploads: usize,
221    pub planned_snapshot_loads: usize,
222    pub planned_code_reinstalls: usize,
223    pub planned_verification_checks: usize,
224    #[serde(default)]
225    pub planned_operations: usize,
226    pub rendered_operations: usize,
227    #[serde(default)]
228    pub operation_counts: RestoreApplyOperationKindCounts,
229    pub artifact_validation: Option<RestoreApplyArtifactValidation>,
230    pub phases: Vec<RestoreApplyDryRunPhase>,
231}
232
233impl RestoreApplyDryRun {
234    /// Build a no-mutation apply dry-run after validating optional status identity.
235    pub fn try_from_plan(
236        plan: &RestorePlan,
237        status: Option<&RestoreStatus>,
238    ) -> Result<Self, RestoreApplyDryRunError> {
239        if let Some(status) = status {
240            validate_restore_status_matches_plan(plan, status)?;
241        }
242
243        Ok(Self::from_validated_plan(plan, status))
244    }
245
246    /// Build an apply dry-run and verify all referenced artifacts under a backup root.
247    pub fn try_from_plan_with_artifacts(
248        plan: &RestorePlan,
249        status: Option<&RestoreStatus>,
250        backup_root: &Path,
251    ) -> Result<Self, RestoreApplyDryRunError> {
252        let mut dry_run = Self::try_from_plan(plan, status)?;
253        dry_run.artifact_validation = Some(validate_restore_apply_artifacts(plan, backup_root)?);
254        Ok(dry_run)
255    }
256
257    // Build a no-mutation apply dry-run after any supplied status is validated.
258    fn from_validated_plan(plan: &RestorePlan, status: Option<&RestoreStatus>) -> Self {
259        let mut next_sequence = 0;
260        let phases = plan
261            .phases
262            .iter()
263            .map(|phase| RestoreApplyDryRunPhase::from_plan_phase(phase, &mut next_sequence))
264            .collect::<Vec<_>>();
265        let mut phases = phases;
266        append_fleet_verification_operations(plan, &mut phases, &mut next_sequence);
267        let rendered_operations = phases
268            .iter()
269            .map(|phase| phase.operations.len())
270            .sum::<usize>();
271        let operation_counts = RestoreApplyOperationKindCounts::from_dry_run_phases(&phases);
272
273        Self {
274            dry_run_version: 1,
275            backup_id: plan.backup_id.clone(),
276            ready: status.map_or(plan.readiness_summary.ready, |status| status.ready),
277            readiness_reasons: status.map_or_else(
278                || plan.readiness_summary.reasons.clone(),
279                |status| status.readiness_reasons.clone(),
280            ),
281            member_count: plan.member_count,
282            phase_count: plan.ordering_summary.phase_count,
283            status_supplied: status.is_some(),
284            planned_snapshot_uploads: plan
285                .operation_summary
286                .effective_planned_snapshot_uploads(plan.member_count),
287            planned_snapshot_loads: plan.operation_summary.planned_snapshot_loads,
288            planned_code_reinstalls: plan.operation_summary.planned_code_reinstalls,
289            planned_verification_checks: plan.operation_summary.planned_verification_checks,
290            planned_operations: plan
291                .operation_summary
292                .effective_planned_operations(plan.member_count),
293            rendered_operations,
294            operation_counts,
295            artifact_validation: None,
296            phases,
297        }
298    }
299}
300
301///
302/// RestoreApplyJournal
303///
304
305#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
306pub struct RestoreApplyJournal {
307    pub journal_version: u16,
308    pub backup_id: String,
309    pub ready: bool,
310    pub blocked_reasons: Vec<String>,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub backup_root: Option<String>,
313    pub operation_count: usize,
314    #[serde(default)]
315    pub operation_counts: RestoreApplyOperationKindCounts,
316    pub pending_operations: usize,
317    pub ready_operations: usize,
318    pub blocked_operations: usize,
319    pub completed_operations: usize,
320    pub failed_operations: usize,
321    pub operations: Vec<RestoreApplyJournalOperation>,
322    #[serde(default, skip_serializing_if = "Vec::is_empty")]
323    pub operation_receipts: Vec<RestoreApplyOperationReceipt>,
324}
325
326impl RestoreApplyJournal {
327    /// Build the initial no-mutation restore apply journal from a dry-run.
328    #[must_use]
329    pub fn from_dry_run(dry_run: &RestoreApplyDryRun) -> Self {
330        let blocked_reasons = restore_apply_blocked_reasons(dry_run);
331        let initial_state = if blocked_reasons.is_empty() {
332            RestoreApplyOperationState::Ready
333        } else {
334            RestoreApplyOperationState::Blocked
335        };
336        let operations = dry_run
337            .phases
338            .iter()
339            .flat_map(|phase| phase.operations.iter())
340            .map(|operation| {
341                RestoreApplyJournalOperation::from_dry_run_operation(
342                    operation,
343                    initial_state.clone(),
344                    &blocked_reasons,
345                )
346            })
347            .collect::<Vec<_>>();
348        let ready_operations = operations
349            .iter()
350            .filter(|operation| operation.state == RestoreApplyOperationState::Ready)
351            .count();
352        let blocked_operations = operations
353            .iter()
354            .filter(|operation| operation.state == RestoreApplyOperationState::Blocked)
355            .count();
356        let operation_counts = RestoreApplyOperationKindCounts::from_operations(&operations);
357
358        Self {
359            journal_version: 1,
360            backup_id: dry_run.backup_id.clone(),
361            ready: blocked_reasons.is_empty(),
362            blocked_reasons,
363            backup_root: dry_run
364                .artifact_validation
365                .as_ref()
366                .map(|validation| validation.backup_root.clone()),
367            operation_count: operations.len(),
368            operation_counts,
369            pending_operations: 0,
370            ready_operations,
371            blocked_operations,
372            completed_operations: 0,
373            failed_operations: 0,
374            operations,
375            operation_receipts: Vec::new(),
376        }
377    }
378
379    /// Validate the structural consistency of a restore apply journal.
380    pub fn validate(&self) -> Result<(), RestoreApplyJournalError> {
381        validate_apply_journal_version(self.journal_version)?;
382        validate_apply_journal_nonempty("backup_id", &self.backup_id)?;
383        if let Some(backup_root) = &self.backup_root {
384            validate_apply_journal_nonempty("backup_root", backup_root)?;
385        }
386        validate_apply_journal_count(
387            "operation_count",
388            self.operation_count,
389            self.operations.len(),
390        )?;
391
392        let state_counts = RestoreApplyJournalStateCounts::from_operations(&self.operations);
393        let operation_counts = RestoreApplyOperationKindCounts::from_operations(&self.operations);
394        self.operation_counts
395            .validate_matches_if_supplied(&operation_counts)?;
396        validate_apply_journal_count(
397            "pending_operations",
398            self.pending_operations,
399            state_counts.pending,
400        )?;
401        validate_apply_journal_count(
402            "ready_operations",
403            self.ready_operations,
404            state_counts.ready,
405        )?;
406        validate_apply_journal_count(
407            "blocked_operations",
408            self.blocked_operations,
409            state_counts.blocked,
410        )?;
411        validate_apply_journal_count(
412            "completed_operations",
413            self.completed_operations,
414            state_counts.completed,
415        )?;
416        validate_apply_journal_count(
417            "failed_operations",
418            self.failed_operations,
419            state_counts.failed,
420        )?;
421
422        if self.ready && (!self.blocked_reasons.is_empty() || self.blocked_operations > 0) {
423            return Err(RestoreApplyJournalError::ReadyJournalHasBlockingState);
424        }
425
426        validate_apply_journal_sequences(&self.operations)?;
427        for operation in &self.operations {
428            operation.validate()?;
429        }
430        for receipt in &self.operation_receipts {
431            receipt.validate_against(self)?;
432        }
433
434        Ok(())
435    }
436
437    /// Summarize this apply journal for operators and automation.
438    #[must_use]
439    pub fn status(&self) -> RestoreApplyJournalStatus {
440        RestoreApplyJournalStatus::from_journal(self)
441    }
442
443    /// Build an operator-oriented report from this apply journal.
444    #[must_use]
445    pub fn report(&self) -> RestoreApplyJournalReport {
446        RestoreApplyJournalReport::from_journal(self)
447    }
448
449    /// Return the full next ready operation row, if one is available.
450    #[must_use]
451    pub fn next_ready_operation(&self) -> Option<&RestoreApplyJournalOperation> {
452        self.operations
453            .iter()
454            .filter(|operation| operation.state == RestoreApplyOperationState::Ready)
455            .min_by_key(|operation| operation.sequence)
456    }
457
458    /// Return the next ready or pending operation that controls runner progress.
459    #[must_use]
460    pub fn next_transition_operation(&self) -> Option<&RestoreApplyJournalOperation> {
461        self.operations
462            .iter()
463            .filter(|operation| {
464                matches!(
465                    operation.state,
466                    RestoreApplyOperationState::Ready
467                        | RestoreApplyOperationState::Pending
468                        | RestoreApplyOperationState::Failed
469                )
470            })
471            .min_by_key(|operation| operation.sequence)
472    }
473
474    /// Render the next transitionable operation as a compact runner response.
475    #[must_use]
476    pub fn next_operation(&self) -> RestoreApplyNextOperation {
477        RestoreApplyNextOperation::from_journal(self)
478    }
479
480    /// Render the next transitionable operation as a no-execute command preview.
481    #[must_use]
482    pub fn next_command_preview(&self) -> RestoreApplyCommandPreview {
483        RestoreApplyCommandPreview::from_journal(self)
484    }
485
486    /// Render the next transitionable operation with a configured command preview.
487    #[must_use]
488    pub fn next_command_preview_with_config(
489        &self,
490        config: &RestoreApplyCommandConfig,
491    ) -> RestoreApplyCommandPreview {
492        RestoreApplyCommandPreview::from_journal_with_config(self, config)
493    }
494
495    /// Store one durable operation receipt/output and revalidate the journal.
496    pub fn record_operation_receipt(
497        &mut self,
498        receipt: RestoreApplyOperationReceipt,
499    ) -> Result<(), RestoreApplyJournalError> {
500        self.operation_receipts.push(receipt);
501        if let Err(error) = self.validate() {
502            self.operation_receipts.pop();
503            return Err(error);
504        }
505
506        Ok(())
507    }
508
509    /// Mark the next transitionable operation pending and refresh journal counts.
510    pub fn mark_next_operation_pending(&mut self) -> Result<(), RestoreApplyJournalError> {
511        self.mark_next_operation_pending_at(None)
512    }
513
514    /// Mark the next transitionable operation pending with an update marker.
515    pub fn mark_next_operation_pending_at(
516        &mut self,
517        updated_at: Option<String>,
518    ) -> Result<(), RestoreApplyJournalError> {
519        let sequence = self
520            .next_transition_sequence()
521            .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
522        self.mark_operation_pending_at(sequence, updated_at)
523    }
524
525    /// Mark one restore apply operation pending and refresh journal counts.
526    pub fn mark_operation_pending(
527        &mut self,
528        sequence: usize,
529    ) -> Result<(), RestoreApplyJournalError> {
530        self.mark_operation_pending_at(sequence, None)
531    }
532
533    /// Mark one restore apply operation pending with an update marker.
534    pub fn mark_operation_pending_at(
535        &mut self,
536        sequence: usize,
537        updated_at: Option<String>,
538    ) -> Result<(), RestoreApplyJournalError> {
539        self.transition_operation(
540            sequence,
541            RestoreApplyOperationState::Pending,
542            Vec::new(),
543            updated_at,
544        )
545    }
546
547    /// Mark the current pending operation ready again and refresh counts.
548    pub fn mark_next_operation_ready(&mut self) -> Result<(), RestoreApplyJournalError> {
549        self.mark_next_operation_ready_at(None)
550    }
551
552    /// Mark the current pending operation ready again with an update marker.
553    pub fn mark_next_operation_ready_at(
554        &mut self,
555        updated_at: Option<String>,
556    ) -> Result<(), RestoreApplyJournalError> {
557        let operation = self
558            .next_transition_operation()
559            .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
560        if operation.state != RestoreApplyOperationState::Pending {
561            return Err(RestoreApplyJournalError::NoPendingOperation);
562        }
563
564        self.mark_operation_ready_at(operation.sequence, updated_at)
565    }
566
567    /// Mark one restore apply operation ready again and refresh journal counts.
568    pub fn mark_operation_ready(
569        &mut self,
570        sequence: usize,
571    ) -> Result<(), RestoreApplyJournalError> {
572        self.mark_operation_ready_at(sequence, None)
573    }
574
575    /// Mark one restore apply operation ready again with an update marker.
576    pub fn mark_operation_ready_at(
577        &mut self,
578        sequence: usize,
579        updated_at: Option<String>,
580    ) -> Result<(), RestoreApplyJournalError> {
581        self.transition_operation(
582            sequence,
583            RestoreApplyOperationState::Ready,
584            Vec::new(),
585            updated_at,
586        )
587    }
588
589    /// Retry one failed restore apply operation by moving it back to ready.
590    pub fn retry_failed_operation_at(
591        &mut self,
592        sequence: usize,
593        updated_at: Option<String>,
594    ) -> Result<(), RestoreApplyJournalError> {
595        self.transition_operation(
596            sequence,
597            RestoreApplyOperationState::Ready,
598            Vec::new(),
599            updated_at,
600        )
601    }
602
603    /// Mark one restore apply operation completed and refresh journal counts.
604    pub fn mark_operation_completed(
605        &mut self,
606        sequence: usize,
607    ) -> Result<(), RestoreApplyJournalError> {
608        self.mark_operation_completed_at(sequence, None)
609    }
610
611    /// Mark one restore apply operation completed with an update marker.
612    pub fn mark_operation_completed_at(
613        &mut self,
614        sequence: usize,
615        updated_at: Option<String>,
616    ) -> Result<(), RestoreApplyJournalError> {
617        self.transition_operation(
618            sequence,
619            RestoreApplyOperationState::Completed,
620            Vec::new(),
621            updated_at,
622        )
623    }
624
625    /// Mark one restore apply operation failed and refresh journal counts.
626    pub fn mark_operation_failed(
627        &mut self,
628        sequence: usize,
629        reason: String,
630    ) -> Result<(), RestoreApplyJournalError> {
631        self.mark_operation_failed_at(sequence, reason, None)
632    }
633
634    /// Mark one restore apply operation failed with an update marker.
635    pub fn mark_operation_failed_at(
636        &mut self,
637        sequence: usize,
638        reason: String,
639        updated_at: Option<String>,
640    ) -> Result<(), RestoreApplyJournalError> {
641        if reason.trim().is_empty() {
642            return Err(RestoreApplyJournalError::FailureReasonRequired(sequence));
643        }
644
645        self.transition_operation(
646            sequence,
647            RestoreApplyOperationState::Failed,
648            vec![reason],
649            updated_at,
650        )
651    }
652
653    // Apply one legal operation state transition and revalidate the journal.
654    fn transition_operation(
655        &mut self,
656        sequence: usize,
657        next_state: RestoreApplyOperationState,
658        blocking_reasons: Vec<String>,
659        updated_at: Option<String>,
660    ) -> Result<(), RestoreApplyJournalError> {
661        let index = self
662            .operations
663            .iter()
664            .position(|operation| operation.sequence == sequence)
665            .ok_or(RestoreApplyJournalError::OperationNotFound(sequence))?;
666        let operation = &self.operations[index];
667
668        if !operation.can_transition_to(&next_state) {
669            return Err(RestoreApplyJournalError::InvalidOperationTransition {
670                sequence,
671                from: operation.state.clone(),
672                to: next_state,
673            });
674        }
675
676        self.validate_operation_transition_order(operation, &next_state)?;
677
678        let operation = &mut self.operations[index];
679        operation.state = next_state;
680        operation.blocking_reasons = blocking_reasons;
681        operation.state_updated_at = updated_at;
682        self.refresh_operation_counts();
683        self.validate()
684    }
685
686    // Ensure fresh operation transitions advance in journal order.
687    fn validate_operation_transition_order(
688        &self,
689        operation: &RestoreApplyJournalOperation,
690        next_state: &RestoreApplyOperationState,
691    ) -> Result<(), RestoreApplyJournalError> {
692        if operation.state == *next_state {
693            return Ok(());
694        }
695
696        let next_sequence = self
697            .next_transition_sequence()
698            .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
699
700        if operation.sequence == next_sequence {
701            return Ok(());
702        }
703
704        Err(RestoreApplyJournalError::OutOfOrderOperationTransition {
705            requested: operation.sequence,
706            next: next_sequence,
707        })
708    }
709
710    // Return the next operation sequence that can be advanced by a runner.
711    fn next_transition_sequence(&self) -> Option<usize> {
712        self.next_transition_operation()
713            .map(|operation| operation.sequence)
714    }
715
716    // Recompute operation counts after a journal operation state change.
717    fn refresh_operation_counts(&mut self) {
718        let state_counts = RestoreApplyJournalStateCounts::from_operations(&self.operations);
719        self.operation_count = self.operations.len();
720        self.operation_counts = RestoreApplyOperationKindCounts::from_operations(&self.operations);
721        self.pending_operations = state_counts.pending;
722        self.ready_operations = state_counts.ready;
723        self.blocked_operations = state_counts.blocked;
724        self.completed_operations = state_counts.completed;
725        self.failed_operations = state_counts.failed;
726    }
727
728    // Return whether this journal carried a persisted operation-kind receipt.
729    const fn operation_counts_supplied(&self) -> bool {
730        !self.operation_counts.is_empty() || self.operations.is_empty()
731    }
732
733    // Find the uploaded target snapshot ID required by one load operation.
734    fn uploaded_snapshot_id_for_load(&self, load: &RestoreApplyJournalOperation) -> Option<&str> {
735        self.operation_receipts
736            .iter()
737            .find(|receipt| {
738                receipt.matches_load_operation(load)
739                    && self.operations.iter().any(|operation| {
740                        operation.sequence == receipt.sequence
741                            && operation.operation == RestoreApplyOperationKind::UploadSnapshot
742                            && operation.state == RestoreApplyOperationState::Completed
743                    })
744            })
745            .and_then(|receipt| receipt.uploaded_snapshot_id.as_deref())
746    }
747}
748
749// Validate the supported restore apply journal format version.
750const fn validate_apply_journal_version(version: u16) -> Result<(), RestoreApplyJournalError> {
751    if version == 1 {
752        return Ok(());
753    }
754
755    Err(RestoreApplyJournalError::UnsupportedVersion(version))
756}
757
758// Validate required nonempty restore apply journal fields.
759fn validate_apply_journal_nonempty(
760    field: &'static str,
761    value: &str,
762) -> Result<(), RestoreApplyJournalError> {
763    if !value.trim().is_empty() {
764        return Ok(());
765    }
766
767    Err(RestoreApplyJournalError::MissingField(field))
768}
769
770// Validate one reported restore apply journal count.
771const fn validate_apply_journal_count(
772    field: &'static str,
773    reported: usize,
774    actual: usize,
775) -> Result<(), RestoreApplyJournalError> {
776    if reported == actual {
777        return Ok(());
778    }
779
780    Err(RestoreApplyJournalError::CountMismatch {
781        field,
782        reported,
783        actual,
784    })
785}
786
787// Validate operation sequence values are unique and contiguous from zero.
788fn validate_apply_journal_sequences(
789    operations: &[RestoreApplyJournalOperation],
790) -> Result<(), RestoreApplyJournalError> {
791    let mut sequences = BTreeSet::new();
792    for operation in operations {
793        if !sequences.insert(operation.sequence) {
794            return Err(RestoreApplyJournalError::DuplicateSequence(
795                operation.sequence,
796            ));
797        }
798    }
799
800    for expected in 0..operations.len() {
801        if !sequences.contains(&expected) {
802            return Err(RestoreApplyJournalError::MissingSequence(expected));
803        }
804    }
805
806    Ok(())
807}
808
809///
810/// RestoreApplyJournalStateCounts
811///
812
813#[derive(Clone, Debug, Default, Eq, PartialEq)]
814struct RestoreApplyJournalStateCounts {
815    pending: usize,
816    ready: usize,
817    blocked: usize,
818    completed: usize,
819    failed: usize,
820}
821
822impl RestoreApplyJournalStateCounts {
823    // Count operation states from concrete journal operation rows.
824    fn from_operations(operations: &[RestoreApplyJournalOperation]) -> Self {
825        let mut counts = Self::default();
826        for operation in operations {
827            match operation.state {
828                RestoreApplyOperationState::Pending => counts.pending += 1,
829                RestoreApplyOperationState::Ready => counts.ready += 1,
830                RestoreApplyOperationState::Blocked => counts.blocked += 1,
831                RestoreApplyOperationState::Completed => counts.completed += 1,
832                RestoreApplyOperationState::Failed => counts.failed += 1,
833            }
834        }
835        counts
836    }
837}
838
839///
840/// RestoreApplyOperationKindCounts
841///
842
843#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
844pub struct RestoreApplyOperationKindCounts {
845    pub snapshot_uploads: usize,
846    pub snapshot_loads: usize,
847    pub code_reinstalls: usize,
848    pub member_verifications: usize,
849    pub fleet_verifications: usize,
850    pub verification_operations: usize,
851}
852
853impl RestoreApplyOperationKindCounts {
854    /// Count restore apply journal operations by runner operation kind.
855    #[must_use]
856    pub fn from_operations(operations: &[RestoreApplyJournalOperation]) -> Self {
857        let mut counts = Self::default();
858        for operation in operations {
859            counts.record(&operation.operation);
860        }
861        counts
862    }
863
864    /// Validate this count object against concrete operations when it was supplied.
865    pub fn validate_matches_if_supplied(
866        &self,
867        expected: &Self,
868    ) -> Result<(), RestoreApplyJournalError> {
869        if self.is_empty() && !expected.is_empty() {
870            return Ok(());
871        }
872
873        validate_apply_journal_count(
874            "operation_counts.snapshot_uploads",
875            self.snapshot_uploads,
876            expected.snapshot_uploads,
877        )?;
878        validate_apply_journal_count(
879            "operation_counts.snapshot_loads",
880            self.snapshot_loads,
881            expected.snapshot_loads,
882        )?;
883        validate_apply_journal_count(
884            "operation_counts.code_reinstalls",
885            self.code_reinstalls,
886            expected.code_reinstalls,
887        )?;
888        validate_apply_journal_count(
889            "operation_counts.member_verifications",
890            self.member_verifications,
891            expected.member_verifications,
892        )?;
893        validate_apply_journal_count(
894            "operation_counts.fleet_verifications",
895            self.fleet_verifications,
896            expected.fleet_verifications,
897        )?;
898        validate_apply_journal_count(
899            "operation_counts.verification_operations",
900            self.verification_operations,
901            expected.verification_operations,
902        )
903    }
904
905    // Return whether no operation-kind counts are present.
906    const fn is_empty(&self) -> bool {
907        self.snapshot_uploads == 0
908            && self.snapshot_loads == 0
909            && self.code_reinstalls == 0
910            && self.member_verifications == 0
911            && self.fleet_verifications == 0
912            && self.verification_operations == 0
913    }
914
915    /// Count restore apply dry-run operations by runner operation kind.
916    #[must_use]
917    pub fn from_dry_run_phases(phases: &[RestoreApplyDryRunPhase]) -> Self {
918        let mut counts = Self::default();
919        for operation in phases.iter().flat_map(|phase| {
920            phase
921                .operations
922                .iter()
923                .map(|operation| &operation.operation)
924        }) {
925            counts.record(operation);
926        }
927        counts
928    }
929
930    // Record one operation kind in the aggregate count object.
931    const fn record(&mut self, operation: &RestoreApplyOperationKind) {
932        match operation {
933            RestoreApplyOperationKind::UploadSnapshot => self.snapshot_uploads += 1,
934            RestoreApplyOperationKind::LoadSnapshot => self.snapshot_loads += 1,
935            RestoreApplyOperationKind::ReinstallCode => self.code_reinstalls += 1,
936            RestoreApplyOperationKind::VerifyMember => {
937                self.member_verifications += 1;
938                self.verification_operations += 1;
939            }
940            RestoreApplyOperationKind::VerifyFleet => {
941                self.fleet_verifications += 1;
942                self.verification_operations += 1;
943            }
944        }
945    }
946}
947
948///
949/// RestoreApplyOperationReceipt
950///
951
952#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
953pub struct RestoreApplyOperationReceipt {
954    pub sequence: usize,
955    pub operation: RestoreApplyOperationKind,
956    #[serde(default)]
957    pub outcome: RestoreApplyOperationReceiptOutcome,
958    pub source_canister: String,
959    pub target_canister: String,
960    #[serde(default)]
961    pub attempt: usize,
962    #[serde(skip_serializing_if = "Option::is_none")]
963    pub updated_at: Option<String>,
964    #[serde(skip_serializing_if = "Option::is_none")]
965    pub command: Option<RestoreApplyRunnerCommand>,
966    #[serde(skip_serializing_if = "Option::is_none")]
967    pub status: Option<String>,
968    #[serde(skip_serializing_if = "Option::is_none")]
969    pub stdout: Option<RestoreApplyCommandOutput>,
970    #[serde(skip_serializing_if = "Option::is_none")]
971    pub stderr: Option<RestoreApplyCommandOutput>,
972    #[serde(skip_serializing_if = "Option::is_none")]
973    pub failure_reason: Option<String>,
974    #[serde(skip_serializing_if = "Option::is_none")]
975    pub source_snapshot_id: Option<String>,
976    #[serde(skip_serializing_if = "Option::is_none")]
977    pub artifact_path: Option<String>,
978    #[serde(skip_serializing_if = "Option::is_none")]
979    pub uploaded_snapshot_id: Option<String>,
980}
981
982impl RestoreApplyOperationReceipt {
983    /// Build a completed upload receipt from the uploaded target-side snapshot ID.
984    #[must_use]
985    pub fn completed_upload(
986        operation: &RestoreApplyJournalOperation,
987        uploaded_snapshot_id: String,
988    ) -> Self {
989        Self {
990            sequence: operation.sequence,
991            operation: RestoreApplyOperationKind::UploadSnapshot,
992            outcome: RestoreApplyOperationReceiptOutcome::CommandCompleted,
993            source_canister: operation.source_canister.clone(),
994            target_canister: operation.target_canister.clone(),
995            attempt: 1,
996            updated_at: None,
997            command: None,
998            status: None,
999            stdout: None,
1000            stderr: None,
1001            failure_reason: None,
1002            source_snapshot_id: operation.snapshot_id.clone(),
1003            artifact_path: operation.artifact_path.clone(),
1004            uploaded_snapshot_id: Some(uploaded_snapshot_id),
1005        }
1006    }
1007
1008    /// Build a durable completed-command receipt for the apply journal.
1009    #[must_use]
1010    pub fn command_completed(
1011        operation: &RestoreApplyJournalOperation,
1012        command: RestoreApplyRunnerCommand,
1013        status: String,
1014        updated_at: Option<String>,
1015        output: RestoreApplyCommandOutputPair,
1016        attempt: usize,
1017        uploaded_snapshot_id: Option<String>,
1018    ) -> Self {
1019        Self {
1020            sequence: operation.sequence,
1021            operation: operation.operation.clone(),
1022            outcome: RestoreApplyOperationReceiptOutcome::CommandCompleted,
1023            source_canister: operation.source_canister.clone(),
1024            target_canister: operation.target_canister.clone(),
1025            attempt,
1026            updated_at,
1027            command: Some(command),
1028            status: Some(status),
1029            stdout: Some(output.stdout),
1030            stderr: Some(output.stderr),
1031            failure_reason: None,
1032            source_snapshot_id: operation.snapshot_id.clone(),
1033            artifact_path: operation.artifact_path.clone(),
1034            uploaded_snapshot_id,
1035        }
1036    }
1037
1038    /// Build a durable failed-command receipt for the apply journal.
1039    #[must_use]
1040    pub fn command_failed(
1041        operation: &RestoreApplyJournalOperation,
1042        command: RestoreApplyRunnerCommand,
1043        status: String,
1044        updated_at: Option<String>,
1045        output: RestoreApplyCommandOutputPair,
1046        attempt: usize,
1047        failure_reason: String,
1048    ) -> Self {
1049        Self {
1050            sequence: operation.sequence,
1051            operation: operation.operation.clone(),
1052            outcome: RestoreApplyOperationReceiptOutcome::CommandFailed,
1053            source_canister: operation.source_canister.clone(),
1054            target_canister: operation.target_canister.clone(),
1055            attempt,
1056            updated_at,
1057            command: Some(command),
1058            status: Some(status),
1059            stdout: Some(output.stdout),
1060            stderr: Some(output.stderr),
1061            failure_reason: Some(failure_reason),
1062            source_snapshot_id: operation.snapshot_id.clone(),
1063            artifact_path: operation.artifact_path.clone(),
1064            uploaded_snapshot_id: None,
1065        }
1066    }
1067
1068    // Return whether this upload receipt satisfies one later load operation.
1069    fn matches_load_operation(&self, load: &RestoreApplyJournalOperation) -> bool {
1070        self.operation == RestoreApplyOperationKind::UploadSnapshot
1071            && self.outcome == RestoreApplyOperationReceiptOutcome::CommandCompleted
1072            && load.operation == RestoreApplyOperationKind::LoadSnapshot
1073            && self.source_canister == load.source_canister
1074            && self.target_canister == load.target_canister
1075            && self.source_snapshot_id == load.snapshot_id
1076            && self.artifact_path == load.artifact_path
1077            && self
1078                .uploaded_snapshot_id
1079                .as_ref()
1080                .is_some_and(|id| !id.trim().is_empty())
1081    }
1082
1083    // Validate one durable operation receipt against the journal operation rows.
1084    fn validate_against(
1085        &self,
1086        journal: &RestoreApplyJournal,
1087    ) -> Result<(), RestoreApplyJournalError> {
1088        let operation = journal
1089            .operations
1090            .iter()
1091            .find(|operation| operation.sequence == self.sequence)
1092            .ok_or(RestoreApplyJournalError::OperationReceiptOperationNotFound(
1093                self.sequence,
1094            ))?;
1095        if operation.operation != self.operation
1096            || operation.source_canister != self.source_canister
1097            || operation.target_canister != self.target_canister
1098        {
1099            return Err(RestoreApplyJournalError::OperationReceiptMismatch {
1100                sequence: self.sequence,
1101            });
1102        }
1103        if self.operation == RestoreApplyOperationKind::UploadSnapshot {
1104            validate_apply_journal_nonempty(
1105                "operation_receipts[].source_snapshot_id",
1106                self.source_snapshot_id.as_deref().unwrap_or_default(),
1107            )?;
1108            validate_apply_journal_nonempty(
1109                "operation_receipts[].artifact_path",
1110                self.artifact_path.as_deref().unwrap_or_default(),
1111            )?;
1112            if self.outcome == RestoreApplyOperationReceiptOutcome::CommandCompleted {
1113                validate_apply_journal_nonempty(
1114                    "operation_receipts[].uploaded_snapshot_id",
1115                    self.uploaded_snapshot_id.as_deref().unwrap_or_default(),
1116                )?;
1117            }
1118        }
1119        if self.outcome == RestoreApplyOperationReceiptOutcome::CommandFailed {
1120            validate_apply_journal_nonempty(
1121                "operation_receipts[].failure_reason",
1122                self.failure_reason.as_deref().unwrap_or_default(),
1123            )?;
1124        }
1125
1126        Ok(())
1127    }
1128}
1129
1130///
1131/// RestoreApplyOperationReceiptOutcome
1132///
1133
1134#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1135#[serde(rename_all = "kebab-case")]
1136pub enum RestoreApplyOperationReceiptOutcome {
1137    #[default]
1138    CommandCompleted,
1139    CommandFailed,
1140}
1141
1142///
1143/// RestoreApplyCommandOutput
1144///
1145
1146#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1147pub struct RestoreApplyCommandOutput {
1148    pub text: String,
1149    pub truncated: bool,
1150    pub original_bytes: usize,
1151}
1152
1153impl RestoreApplyCommandOutput {
1154    /// Build a bounded UTF-8-ish command output payload for durable receipts.
1155    #[must_use]
1156    pub fn from_bytes(bytes: &[u8], limit: usize) -> Self {
1157        let original_bytes = bytes.len();
1158        let start = original_bytes.saturating_sub(limit);
1159        Self {
1160            text: String::from_utf8_lossy(&bytes[start..]).to_string(),
1161            truncated: start > 0,
1162            original_bytes,
1163        }
1164    }
1165}
1166
1167///
1168/// RestoreApplyCommandOutputPair
1169///
1170
1171#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1172pub struct RestoreApplyCommandOutputPair {
1173    pub stdout: RestoreApplyCommandOutput,
1174    pub stderr: RestoreApplyCommandOutput,
1175}
1176
1177impl RestoreApplyCommandOutputPair {
1178    /// Build bounded stdout/stderr command output payloads.
1179    #[must_use]
1180    pub fn from_bytes(stdout: &[u8], stderr: &[u8], limit: usize) -> Self {
1181        Self {
1182            stdout: RestoreApplyCommandOutput::from_bytes(stdout, limit),
1183            stderr: RestoreApplyCommandOutput::from_bytes(stderr, limit),
1184        }
1185    }
1186}
1187
1188// Explain why an apply journal is blocked before mutation is allowed.
1189fn restore_apply_blocked_reasons(dry_run: &RestoreApplyDryRun) -> Vec<String> {
1190    let mut reasons = dry_run.readiness_reasons.clone();
1191
1192    match &dry_run.artifact_validation {
1193        Some(validation) => {
1194            if !validation.artifacts_present {
1195                reasons.push("missing-artifacts".to_string());
1196            }
1197            if !validation.checksums_verified {
1198                reasons.push("artifact-checksum-validation-incomplete".to_string());
1199            }
1200        }
1201        None => reasons.push("missing-artifact-validation".to_string()),
1202    }
1203
1204    reasons.sort();
1205    reasons.dedup();
1206    reasons
1207}
1208
1209///
1210/// RestoreApplyJournalStatus
1211///
1212
1213#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1214pub struct RestoreApplyJournalStatus {
1215    pub status_version: u16,
1216    pub backup_id: String,
1217    pub ready: bool,
1218    pub complete: bool,
1219    pub blocked_reasons: Vec<String>,
1220    pub operation_count: usize,
1221    #[serde(default)]
1222    pub operation_counts: RestoreApplyOperationKindCounts,
1223    pub operation_counts_supplied: bool,
1224    pub progress: RestoreApplyProgressSummary,
1225    pub pending_summary: RestoreApplyPendingSummary,
1226    pub pending_operations: usize,
1227    pub ready_operations: usize,
1228    pub blocked_operations: usize,
1229    pub completed_operations: usize,
1230    pub failed_operations: usize,
1231    pub next_ready_sequence: Option<usize>,
1232    pub next_ready_operation: Option<RestoreApplyOperationKind>,
1233    pub next_transition_sequence: Option<usize>,
1234    pub next_transition_state: Option<RestoreApplyOperationState>,
1235    pub next_transition_operation: Option<RestoreApplyOperationKind>,
1236    pub next_transition_updated_at: Option<String>,
1237}
1238
1239impl RestoreApplyJournalStatus {
1240    /// Build a compact status projection from a restore apply journal.
1241    #[must_use]
1242    pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
1243        let next_ready = journal.next_ready_operation();
1244        let next_transition = journal.next_transition_operation();
1245
1246        Self {
1247            status_version: 1,
1248            backup_id: journal.backup_id.clone(),
1249            ready: journal.ready,
1250            complete: journal.operation_count > 0
1251                && journal.completed_operations == journal.operation_count,
1252            blocked_reasons: journal.blocked_reasons.clone(),
1253            operation_count: journal.operation_count,
1254            operation_counts: RestoreApplyOperationKindCounts::from_operations(&journal.operations),
1255            operation_counts_supplied: journal.operation_counts_supplied(),
1256            progress: RestoreApplyProgressSummary::from_journal(journal),
1257            pending_summary: RestoreApplyPendingSummary::from_journal(journal),
1258            pending_operations: journal.pending_operations,
1259            ready_operations: journal.ready_operations,
1260            blocked_operations: journal.blocked_operations,
1261            completed_operations: journal.completed_operations,
1262            failed_operations: journal.failed_operations,
1263            next_ready_sequence: next_ready.map(|operation| operation.sequence),
1264            next_ready_operation: next_ready.map(|operation| operation.operation.clone()),
1265            next_transition_sequence: next_transition.map(|operation| operation.sequence),
1266            next_transition_state: next_transition.map(|operation| operation.state.clone()),
1267            next_transition_operation: next_transition.map(|operation| operation.operation.clone()),
1268            next_transition_updated_at: next_transition
1269                .and_then(|operation| operation.state_updated_at.clone()),
1270        }
1271    }
1272}
1273
1274///
1275/// RestoreApplyJournalReport
1276///
1277
1278#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1279#[expect(
1280    clippy::struct_excessive_bools,
1281    reason = "apply reports intentionally expose stable JSON flags for operators and CI"
1282)]
1283pub struct RestoreApplyJournalReport {
1284    pub report_version: u16,
1285    pub backup_id: String,
1286    pub outcome: RestoreApplyReportOutcome,
1287    pub attention_required: bool,
1288    pub ready: bool,
1289    pub complete: bool,
1290    pub blocked_reasons: Vec<String>,
1291    pub operation_count: usize,
1292    #[serde(default)]
1293    pub operation_counts: RestoreApplyOperationKindCounts,
1294    pub operation_counts_supplied: bool,
1295    pub progress: RestoreApplyProgressSummary,
1296    pub pending_summary: RestoreApplyPendingSummary,
1297    pub pending_operations: usize,
1298    pub ready_operations: usize,
1299    pub blocked_operations: usize,
1300    pub completed_operations: usize,
1301    pub failed_operations: usize,
1302    pub next_transition: Option<RestoreApplyReportOperation>,
1303    pub pending: Vec<RestoreApplyReportOperation>,
1304    pub failed: Vec<RestoreApplyReportOperation>,
1305    pub blocked: Vec<RestoreApplyReportOperation>,
1306}
1307
1308impl RestoreApplyJournalReport {
1309    /// Build a compact operator report from a restore apply journal.
1310    #[must_use]
1311    pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
1312        let complete =
1313            journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
1314        let outcome = RestoreApplyReportOutcome::from_journal(journal, complete);
1315        let pending = report_operations_with_state(journal, RestoreApplyOperationState::Pending);
1316        let failed = report_operations_with_state(journal, RestoreApplyOperationState::Failed);
1317        let blocked = report_operations_with_state(journal, RestoreApplyOperationState::Blocked);
1318
1319        Self {
1320            report_version: 1,
1321            backup_id: journal.backup_id.clone(),
1322            outcome: outcome.clone(),
1323            attention_required: outcome.attention_required(),
1324            ready: journal.ready,
1325            complete,
1326            blocked_reasons: journal.blocked_reasons.clone(),
1327            operation_count: journal.operation_count,
1328            operation_counts: RestoreApplyOperationKindCounts::from_operations(&journal.operations),
1329            operation_counts_supplied: journal.operation_counts_supplied(),
1330            progress: RestoreApplyProgressSummary::from_journal(journal),
1331            pending_summary: RestoreApplyPendingSummary::from_journal(journal),
1332            pending_operations: journal.pending_operations,
1333            ready_operations: journal.ready_operations,
1334            blocked_operations: journal.blocked_operations,
1335            completed_operations: journal.completed_operations,
1336            failed_operations: journal.failed_operations,
1337            next_transition: journal
1338                .next_transition_operation()
1339                .map(RestoreApplyReportOperation::from_journal_operation),
1340            pending,
1341            failed,
1342            blocked,
1343        }
1344    }
1345}
1346
1347///
1348/// RestoreApplyPendingSummary
1349///
1350
1351#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1352pub struct RestoreApplyPendingSummary {
1353    pub pending_operations: usize,
1354    pub pending_operation_available: bool,
1355    pub pending_sequence: Option<usize>,
1356    pub pending_operation: Option<RestoreApplyOperationKind>,
1357    pub pending_updated_at: Option<String>,
1358    pub pending_updated_at_known: bool,
1359}
1360
1361impl RestoreApplyPendingSummary {
1362    /// Build a compact pending-operation summary from a restore apply journal.
1363    #[must_use]
1364    pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
1365        let pending = journal
1366            .operations
1367            .iter()
1368            .filter(|operation| operation.state == RestoreApplyOperationState::Pending)
1369            .min_by_key(|operation| operation.sequence);
1370        let pending_updated_at = pending.and_then(|operation| operation.state_updated_at.clone());
1371        let pending_updated_at_known = pending_updated_at
1372            .as_deref()
1373            .is_some_and(known_state_update_marker);
1374
1375        Self {
1376            pending_operations: journal.pending_operations,
1377            pending_operation_available: pending.is_some(),
1378            pending_sequence: pending.map(|operation| operation.sequence),
1379            pending_operation: pending.map(|operation| operation.operation.clone()),
1380            pending_updated_at,
1381            pending_updated_at_known,
1382        }
1383    }
1384}
1385
1386// Return whether a journal update marker can be compared by automation.
1387fn known_state_update_marker(value: &str) -> bool {
1388    !value.trim().is_empty() && value != "unknown"
1389}
1390
1391///
1392/// RestoreApplyProgressSummary
1393///
1394
1395#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1396pub struct RestoreApplyProgressSummary {
1397    pub operation_count: usize,
1398    pub completed_operations: usize,
1399    pub remaining_operations: usize,
1400    pub transitionable_operations: usize,
1401    pub attention_operations: usize,
1402    pub completion_basis_points: usize,
1403}
1404
1405impl RestoreApplyProgressSummary {
1406    /// Build a compact progress summary from restore apply journal counters.
1407    #[must_use]
1408    pub const fn from_journal(journal: &RestoreApplyJournal) -> Self {
1409        let remaining_operations = journal
1410            .operation_count
1411            .saturating_sub(journal.completed_operations);
1412        let transitionable_operations = journal.ready_operations + journal.pending_operations;
1413        let attention_operations =
1414            journal.pending_operations + journal.blocked_operations + journal.failed_operations;
1415        let completion_basis_points =
1416            completion_basis_points(journal.completed_operations, journal.operation_count);
1417
1418        Self {
1419            operation_count: journal.operation_count,
1420            completed_operations: journal.completed_operations,
1421            remaining_operations,
1422            transitionable_operations,
1423            attention_operations,
1424            completion_basis_points,
1425        }
1426    }
1427}
1428
1429// Return completion as basis points so JSON stays deterministic and integer-only.
1430const fn completion_basis_points(completed_operations: usize, operation_count: usize) -> usize {
1431    if operation_count == 0 {
1432        return 0;
1433    }
1434
1435    completed_operations.saturating_mul(10_000) / operation_count
1436}
1437
1438///
1439/// RestoreApplyReportOutcome
1440///
1441
1442#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1443#[serde(rename_all = "kebab-case")]
1444pub enum RestoreApplyReportOutcome {
1445    Empty,
1446    Complete,
1447    Failed,
1448    Blocked,
1449    Pending,
1450    InProgress,
1451}
1452
1453impl RestoreApplyReportOutcome {
1454    // Classify the journal into one high-level operator outcome.
1455    const fn from_journal(journal: &RestoreApplyJournal, complete: bool) -> Self {
1456        if journal.operation_count == 0 {
1457            return Self::Empty;
1458        }
1459        if complete {
1460            return Self::Complete;
1461        }
1462        if journal.failed_operations > 0 {
1463            return Self::Failed;
1464        }
1465        if !journal.ready || journal.blocked_operations > 0 {
1466            return Self::Blocked;
1467        }
1468        if journal.pending_operations > 0 {
1469            return Self::Pending;
1470        }
1471        Self::InProgress
1472    }
1473
1474    // Return whether this outcome needs operator or automation attention.
1475    const fn attention_required(&self) -> bool {
1476        matches!(self, Self::Failed | Self::Blocked | Self::Pending)
1477    }
1478}
1479
1480///
1481/// RestoreApplyReportOperation
1482///
1483
1484#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1485pub struct RestoreApplyReportOperation {
1486    pub sequence: usize,
1487    pub operation: RestoreApplyOperationKind,
1488    pub state: RestoreApplyOperationState,
1489    pub restore_group: u16,
1490    pub phase_order: usize,
1491    pub role: String,
1492    pub source_canister: String,
1493    pub target_canister: String,
1494    pub state_updated_at: Option<String>,
1495    pub reasons: Vec<String>,
1496}
1497
1498impl RestoreApplyReportOperation {
1499    // Build one compact report row from one journal operation.
1500    fn from_journal_operation(operation: &RestoreApplyJournalOperation) -> Self {
1501        Self {
1502            sequence: operation.sequence,
1503            operation: operation.operation.clone(),
1504            state: operation.state.clone(),
1505            restore_group: operation.restore_group,
1506            phase_order: operation.phase_order,
1507            role: operation.role.clone(),
1508            source_canister: operation.source_canister.clone(),
1509            target_canister: operation.target_canister.clone(),
1510            state_updated_at: operation.state_updated_at.clone(),
1511            reasons: operation.blocking_reasons.clone(),
1512        }
1513    }
1514}
1515
1516// Return compact report rows for operations in one state.
1517fn report_operations_with_state(
1518    journal: &RestoreApplyJournal,
1519    state: RestoreApplyOperationState,
1520) -> Vec<RestoreApplyReportOperation> {
1521    journal
1522        .operations
1523        .iter()
1524        .filter(|operation| operation.state == state)
1525        .map(RestoreApplyReportOperation::from_journal_operation)
1526        .collect()
1527}
1528
1529///
1530/// RestoreApplyNextOperation
1531///
1532
1533#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1534pub struct RestoreApplyNextOperation {
1535    pub response_version: u16,
1536    pub backup_id: String,
1537    pub ready: bool,
1538    pub complete: bool,
1539    pub operation_available: bool,
1540    pub blocked_reasons: Vec<String>,
1541    pub operation: Option<RestoreApplyJournalOperation>,
1542}
1543
1544impl RestoreApplyNextOperation {
1545    /// Build a compact next-operation response from a restore apply journal.
1546    #[must_use]
1547    pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
1548        let complete =
1549            journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
1550        let operation = journal.next_transition_operation().cloned();
1551
1552        Self {
1553            response_version: 1,
1554            backup_id: journal.backup_id.clone(),
1555            ready: journal.ready,
1556            complete,
1557            operation_available: operation.is_some(),
1558            blocked_reasons: journal.blocked_reasons.clone(),
1559            operation,
1560        }
1561    }
1562}
1563
1564///
1565/// RestoreApplyCommandPreview
1566///
1567
1568#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1569#[expect(
1570    clippy::struct_excessive_bools,
1571    reason = "runner preview exposes machine-readable availability and safety flags"
1572)]
1573pub struct RestoreApplyCommandPreview {
1574    pub response_version: u16,
1575    pub backup_id: String,
1576    pub ready: bool,
1577    pub complete: bool,
1578    pub operation_available: bool,
1579    pub command_available: bool,
1580    pub blocked_reasons: Vec<String>,
1581    pub operation: Option<RestoreApplyJournalOperation>,
1582    pub command: Option<RestoreApplyRunnerCommand>,
1583}
1584
1585impl RestoreApplyCommandPreview {
1586    /// Build a no-execute runner command preview from a restore apply journal.
1587    #[must_use]
1588    pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
1589        Self::from_journal_with_config(journal, &RestoreApplyCommandConfig::default())
1590    }
1591
1592    /// Build a configured no-execute runner command preview from a journal.
1593    #[must_use]
1594    pub fn from_journal_with_config(
1595        journal: &RestoreApplyJournal,
1596        config: &RestoreApplyCommandConfig,
1597    ) -> Self {
1598        let complete =
1599            journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
1600        let operation = journal.next_transition_operation().cloned();
1601        let command = operation.as_ref().and_then(|operation| {
1602            RestoreApplyRunnerCommand::from_operation(operation, journal, config)
1603        });
1604
1605        Self {
1606            response_version: 1,
1607            backup_id: journal.backup_id.clone(),
1608            ready: journal.ready,
1609            complete,
1610            operation_available: operation.is_some(),
1611            command_available: command.is_some(),
1612            blocked_reasons: journal.blocked_reasons.clone(),
1613            operation,
1614            command,
1615        }
1616    }
1617}
1618
1619///
1620/// RestoreApplyCommandConfig
1621///
1622
1623#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1624pub struct RestoreApplyCommandConfig {
1625    pub program: String,
1626    pub network: Option<String>,
1627}
1628
1629impl Default for RestoreApplyCommandConfig {
1630    /// Build the default restore apply command preview configuration.
1631    fn default() -> Self {
1632        Self {
1633            program: "dfx".to_string(),
1634            network: None,
1635        }
1636    }
1637}
1638
1639///
1640/// RestoreApplyRunnerCommand
1641///
1642
1643#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1644pub struct RestoreApplyRunnerCommand {
1645    pub program: String,
1646    pub args: Vec<String>,
1647    pub mutates: bool,
1648    pub requires_stopped_canister: bool,
1649    pub note: String,
1650}
1651
1652impl RestoreApplyRunnerCommand {
1653    // Build a no-execute dfx command preview for one ready operation.
1654    fn from_operation(
1655        operation: &RestoreApplyJournalOperation,
1656        journal: &RestoreApplyJournal,
1657        config: &RestoreApplyCommandConfig,
1658    ) -> Option<Self> {
1659        match operation.operation {
1660            RestoreApplyOperationKind::UploadSnapshot => {
1661                let artifact_path = upload_artifact_command_path(operation, journal)?;
1662                Some(Self {
1663                    program: config.program.clone(),
1664                    args: dfx_canister_args(
1665                        config,
1666                        vec![
1667                            "snapshot".to_string(),
1668                            "upload".to_string(),
1669                            "--dir".to_string(),
1670                            artifact_path,
1671                            operation.target_canister.clone(),
1672                        ],
1673                    ),
1674                    mutates: true,
1675                    requires_stopped_canister: false,
1676                    note: "uploads the downloaded snapshot artifact to the target canister"
1677                        .to_string(),
1678                })
1679            }
1680            RestoreApplyOperationKind::LoadSnapshot => {
1681                let snapshot_id = journal.uploaded_snapshot_id_for_load(operation)?;
1682                Some(Self {
1683                    program: config.program.clone(),
1684                    args: dfx_canister_args(
1685                        config,
1686                        vec![
1687                            "snapshot".to_string(),
1688                            "load".to_string(),
1689                            operation.target_canister.clone(),
1690                            snapshot_id.to_string(),
1691                        ],
1692                    ),
1693                    mutates: true,
1694                    requires_stopped_canister: true,
1695                    note: "loads the uploaded snapshot into the target canister".to_string(),
1696                })
1697            }
1698            RestoreApplyOperationKind::ReinstallCode => Some(Self {
1699                program: config.program.clone(),
1700                args: dfx_canister_args(
1701                    config,
1702                    vec![
1703                        "install".to_string(),
1704                        "--mode".to_string(),
1705                        "reinstall".to_string(),
1706                        "--yes".to_string(),
1707                        operation.target_canister.clone(),
1708                    ],
1709                ),
1710                mutates: true,
1711                requires_stopped_canister: false,
1712                note: "reinstalls target canister code using the local dfx project configuration"
1713                    .to_string(),
1714            }),
1715            RestoreApplyOperationKind::VerifyMember | RestoreApplyOperationKind::VerifyFleet => {
1716                match operation.verification_kind.as_deref() {
1717                    Some("status") => Some(Self {
1718                        program: config.program.clone(),
1719                        args: dfx_canister_args(
1720                            config,
1721                            vec!["status".to_string(), operation.target_canister.clone()],
1722                        ),
1723                        mutates: false,
1724                        requires_stopped_canister: false,
1725                        note: verification_command_note(
1726                            &operation.operation,
1727                            "checks target canister status",
1728                            "checks target fleet root canister status",
1729                        )
1730                        .to_string(),
1731                    }),
1732                    Some(_) => {
1733                        let method = operation.verification_method.as_ref()?;
1734                        Some(Self {
1735                            program: config.program.clone(),
1736                            args: dfx_canister_args(
1737                                config,
1738                                vec![
1739                                    "call".to_string(),
1740                                    "--query".to_string(),
1741                                    operation.target_canister.clone(),
1742                                    method.clone(),
1743                                ],
1744                            ),
1745                            mutates: false,
1746                            requires_stopped_canister: false,
1747                            note: verification_command_note(
1748                                &operation.operation,
1749                                "runs the declared verification method as a query call",
1750                                "runs the declared fleet verification method as a query call",
1751                            )
1752                            .to_string(),
1753                        })
1754                    }
1755                    None => None,
1756                }
1757            }
1758        }
1759    }
1760}
1761
1762// Return an operator note for member-level or fleet-level verification commands.
1763const fn verification_command_note(
1764    operation: &RestoreApplyOperationKind,
1765    member_note: &'static str,
1766    fleet_note: &'static str,
1767) -> &'static str {
1768    match operation {
1769        RestoreApplyOperationKind::VerifyFleet => fleet_note,
1770        RestoreApplyOperationKind::UploadSnapshot
1771        | RestoreApplyOperationKind::LoadSnapshot
1772        | RestoreApplyOperationKind::ReinstallCode
1773        | RestoreApplyOperationKind::VerifyMember => member_note,
1774    }
1775}
1776
1777// Build `dfx canister` arguments with the optional network selector.
1778fn dfx_canister_args(config: &RestoreApplyCommandConfig, mut tail: Vec<String>) -> Vec<String> {
1779    let mut args = vec!["canister".to_string()];
1780    if let Some(network) = &config.network {
1781        args.push("--network".to_string());
1782        args.push(network.clone());
1783    }
1784    args.append(&mut tail);
1785    args
1786}
1787
1788// Resolve upload artifact paths the same way validation resolved them.
1789fn upload_artifact_command_path(
1790    operation: &RestoreApplyJournalOperation,
1791    journal: &RestoreApplyJournal,
1792) -> Option<String> {
1793    let artifact_path = operation.artifact_path.as_ref()?;
1794    let path = Path::new(artifact_path);
1795    if path.is_absolute() {
1796        return Some(artifact_path.clone());
1797    }
1798
1799    let backup_root = journal.backup_root.as_ref()?;
1800    let is_safe = path
1801        .components()
1802        .all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
1803    if !is_safe {
1804        return None;
1805    }
1806
1807    Some(
1808        Path::new(backup_root)
1809            .join(path)
1810            .to_string_lossy()
1811            .to_string(),
1812    )
1813}
1814
1815///
1816/// RestoreApplyJournalOperation
1817///
1818
1819#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1820pub struct RestoreApplyJournalOperation {
1821    pub sequence: usize,
1822    pub operation: RestoreApplyOperationKind,
1823    pub state: RestoreApplyOperationState,
1824    #[serde(default, skip_serializing_if = "Option::is_none")]
1825    pub state_updated_at: Option<String>,
1826    pub blocking_reasons: Vec<String>,
1827    pub restore_group: u16,
1828    pub phase_order: usize,
1829    pub source_canister: String,
1830    pub target_canister: String,
1831    pub role: String,
1832    pub snapshot_id: Option<String>,
1833    pub artifact_path: Option<String>,
1834    pub verification_kind: Option<String>,
1835    pub verification_method: Option<String>,
1836}
1837
1838impl RestoreApplyJournalOperation {
1839    // Build one initial journal operation from the dry-run operation row.
1840    fn from_dry_run_operation(
1841        operation: &RestoreApplyDryRunOperation,
1842        state: RestoreApplyOperationState,
1843        blocked_reasons: &[String],
1844    ) -> Self {
1845        Self {
1846            sequence: operation.sequence,
1847            operation: operation.operation.clone(),
1848            state: state.clone(),
1849            state_updated_at: None,
1850            blocking_reasons: if state == RestoreApplyOperationState::Blocked {
1851                blocked_reasons.to_vec()
1852            } else {
1853                Vec::new()
1854            },
1855            restore_group: operation.restore_group,
1856            phase_order: operation.phase_order,
1857            source_canister: operation.source_canister.clone(),
1858            target_canister: operation.target_canister.clone(),
1859            role: operation.role.clone(),
1860            snapshot_id: operation.snapshot_id.clone(),
1861            artifact_path: operation.artifact_path.clone(),
1862            verification_kind: operation.verification_kind.clone(),
1863            verification_method: operation.verification_method.clone(),
1864        }
1865    }
1866
1867    // Validate one restore apply journal operation row.
1868    fn validate(&self) -> Result<(), RestoreApplyJournalError> {
1869        validate_apply_journal_nonempty("operations[].source_canister", &self.source_canister)?;
1870        validate_apply_journal_nonempty("operations[].target_canister", &self.target_canister)?;
1871        validate_apply_journal_nonempty("operations[].role", &self.role)?;
1872        if let Some(updated_at) = &self.state_updated_at {
1873            validate_apply_journal_nonempty("operations[].state_updated_at", updated_at)?;
1874        }
1875        self.validate_operation_fields()?;
1876
1877        match self.state {
1878            RestoreApplyOperationState::Blocked if self.blocking_reasons.is_empty() => Err(
1879                RestoreApplyJournalError::BlockedOperationMissingReason(self.sequence),
1880            ),
1881            RestoreApplyOperationState::Failed if self.blocking_reasons.is_empty() => Err(
1882                RestoreApplyJournalError::FailureReasonRequired(self.sequence),
1883            ),
1884            RestoreApplyOperationState::Pending
1885            | RestoreApplyOperationState::Ready
1886            | RestoreApplyOperationState::Completed
1887                if !self.blocking_reasons.is_empty() =>
1888            {
1889                Err(RestoreApplyJournalError::UnblockedOperationHasReasons(
1890                    self.sequence,
1891                ))
1892            }
1893            RestoreApplyOperationState::Blocked
1894            | RestoreApplyOperationState::Failed
1895            | RestoreApplyOperationState::Pending
1896            | RestoreApplyOperationState::Ready
1897            | RestoreApplyOperationState::Completed => Ok(()),
1898        }
1899    }
1900
1901    // Validate fields required by the operation kind before runner command rendering.
1902    fn validate_operation_fields(&self) -> Result<(), RestoreApplyJournalError> {
1903        match self.operation {
1904            RestoreApplyOperationKind::UploadSnapshot => self
1905                .validate_required_field("operations[].artifact_path", self.artifact_path.as_ref())
1906                .map(|_| ()),
1907            RestoreApplyOperationKind::LoadSnapshot => self
1908                .validate_required_field("operations[].snapshot_id", self.snapshot_id.as_ref())
1909                .map(|_| ()),
1910            RestoreApplyOperationKind::ReinstallCode => Ok(()),
1911            RestoreApplyOperationKind::VerifyMember | RestoreApplyOperationKind::VerifyFleet => {
1912                let kind = self.validate_required_field(
1913                    "operations[].verification_kind",
1914                    self.verification_kind.as_ref(),
1915                )?;
1916                if kind == "status" {
1917                    return Ok(());
1918                }
1919                self.validate_required_field(
1920                    "operations[].verification_method",
1921                    self.verification_method.as_ref(),
1922                )
1923                .map(|_| ())
1924            }
1925        }
1926    }
1927
1928    // Return one required optional field after checking it is present and nonempty.
1929    fn validate_required_field<'a>(
1930        &self,
1931        field: &'static str,
1932        value: Option<&'a String>,
1933    ) -> Result<&'a str, RestoreApplyJournalError> {
1934        let value = value.map(String::as_str).ok_or_else(|| {
1935            RestoreApplyJournalError::OperationMissingField {
1936                sequence: self.sequence,
1937                operation: self.operation.clone(),
1938                field,
1939            }
1940        })?;
1941        if value.trim().is_empty() {
1942            return Err(RestoreApplyJournalError::OperationMissingField {
1943                sequence: self.sequence,
1944                operation: self.operation.clone(),
1945                field,
1946            });
1947        }
1948
1949        Ok(value)
1950    }
1951
1952    // Decide whether an operation can move to the requested next state.
1953    const fn can_transition_to(&self, next_state: &RestoreApplyOperationState) -> bool {
1954        match (&self.state, next_state) {
1955            (
1956                RestoreApplyOperationState::Ready | RestoreApplyOperationState::Pending,
1957                RestoreApplyOperationState::Pending,
1958            )
1959            | (
1960                RestoreApplyOperationState::Pending | RestoreApplyOperationState::Failed,
1961                RestoreApplyOperationState::Ready,
1962            )
1963            | (
1964                RestoreApplyOperationState::Ready
1965                | RestoreApplyOperationState::Pending
1966                | RestoreApplyOperationState::Completed,
1967                RestoreApplyOperationState::Completed,
1968            )
1969            | (
1970                RestoreApplyOperationState::Ready
1971                | RestoreApplyOperationState::Pending
1972                | RestoreApplyOperationState::Failed,
1973                RestoreApplyOperationState::Failed,
1974            ) => true,
1975            (
1976                RestoreApplyOperationState::Blocked
1977                | RestoreApplyOperationState::Completed
1978                | RestoreApplyOperationState::Failed
1979                | RestoreApplyOperationState::Pending
1980                | RestoreApplyOperationState::Ready,
1981                _,
1982            ) => false,
1983        }
1984    }
1985}
1986
1987///
1988/// RestoreApplyOperationState
1989///
1990
1991#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1992#[serde(rename_all = "kebab-case")]
1993pub enum RestoreApplyOperationState {
1994    Pending,
1995    Ready,
1996    Blocked,
1997    Completed,
1998    Failed,
1999}
2000
2001///
2002/// RestoreApplyJournalError
2003///
2004
2005#[derive(Debug, ThisError)]
2006pub enum RestoreApplyJournalError {
2007    #[error("unsupported restore apply journal version {0}")]
2008    UnsupportedVersion(u16),
2009
2010    #[error("restore apply journal field {0} is required")]
2011    MissingField(&'static str),
2012
2013    #[error("restore apply journal count {field} mismatch: reported={reported}, actual={actual}")]
2014    CountMismatch {
2015        field: &'static str,
2016        reported: usize,
2017        actual: usize,
2018    },
2019
2020    #[error("restore apply journal has duplicate operation sequence {0}")]
2021    DuplicateSequence(usize),
2022
2023    #[error("restore apply journal is missing operation sequence {0}")]
2024    MissingSequence(usize),
2025
2026    #[error("ready restore apply journal cannot include blocked reasons or blocked operations")]
2027    ReadyJournalHasBlockingState,
2028
2029    #[error("blocked restore apply journal operation {0} is missing a blocking reason")]
2030    BlockedOperationMissingReason(usize),
2031
2032    #[error("unblocked restore apply journal operation {0} cannot have blocking reasons")]
2033    UnblockedOperationHasReasons(usize),
2034
2035    #[error("restore apply journal operation {sequence} {operation:?} is missing field {field}")]
2036    OperationMissingField {
2037        sequence: usize,
2038        operation: RestoreApplyOperationKind,
2039        field: &'static str,
2040    },
2041
2042    #[error("restore apply journal operation {0} was not found")]
2043    OperationNotFound(usize),
2044
2045    #[error("restore apply journal operation {sequence} cannot transition from {from:?} to {to:?}")]
2046    InvalidOperationTransition {
2047        sequence: usize,
2048        from: RestoreApplyOperationState,
2049        to: RestoreApplyOperationState,
2050    },
2051
2052    #[error("failed restore apply journal operation {0} requires a reason")]
2053    FailureReasonRequired(usize),
2054
2055    #[error("restore apply journal has no operation that can be advanced")]
2056    NoTransitionableOperation,
2057
2058    #[error("restore apply journal has no pending operation to release")]
2059    NoPendingOperation,
2060
2061    #[error("restore apply journal operation {requested} cannot advance before operation {next}")]
2062    OutOfOrderOperationTransition { requested: usize, next: usize },
2063
2064    #[error("restore apply journal receipt references missing operation {0}")]
2065    OperationReceiptOperationNotFound(usize),
2066
2067    #[error("restore apply journal receipt does not match operation {sequence}")]
2068    OperationReceiptMismatch { sequence: usize },
2069}
2070
2071// Verify every planned restore artifact against one backup directory root.
2072fn validate_restore_apply_artifacts(
2073    plan: &RestorePlan,
2074    backup_root: &Path,
2075) -> Result<RestoreApplyArtifactValidation, RestoreApplyDryRunError> {
2076    let mut checks = Vec::new();
2077
2078    for member in plan.ordered_members() {
2079        checks.push(validate_restore_apply_artifact(member, backup_root)?);
2080    }
2081
2082    let members_with_expected_checksums = checks
2083        .iter()
2084        .filter(|check| check.checksum_expected.is_some())
2085        .count();
2086    let artifacts_present = checks.iter().all(|check| check.exists);
2087    let checksums_verified = members_with_expected_checksums == plan.member_count
2088        && checks.iter().all(|check| check.checksum_verified);
2089
2090    Ok(RestoreApplyArtifactValidation {
2091        backup_root: backup_root.to_string_lossy().to_string(),
2092        checked_members: checks.len(),
2093        artifacts_present,
2094        checksums_verified,
2095        members_with_expected_checksums,
2096        checks,
2097    })
2098}
2099
2100// Verify one planned restore artifact path and checksum.
2101fn validate_restore_apply_artifact(
2102    member: &RestorePlanMember,
2103    backup_root: &Path,
2104) -> Result<RestoreApplyArtifactCheck, RestoreApplyDryRunError> {
2105    let artifact_path = safe_restore_artifact_path(
2106        &member.source_canister,
2107        &member.source_snapshot.artifact_path,
2108    )?;
2109    let resolved_path = backup_root.join(&artifact_path);
2110
2111    if !resolved_path.exists() {
2112        return Err(RestoreApplyDryRunError::ArtifactMissing {
2113            source_canister: member.source_canister.clone(),
2114            artifact_path: member.source_snapshot.artifact_path.clone(),
2115            resolved_path: resolved_path.to_string_lossy().to_string(),
2116        });
2117    }
2118
2119    let (checksum_actual, checksum_verified) =
2120        if let Some(expected) = &member.source_snapshot.checksum {
2121            let checksum = ArtifactChecksum::from_path(&resolved_path).map_err(|source| {
2122                RestoreApplyDryRunError::ArtifactChecksum {
2123                    source_canister: member.source_canister.clone(),
2124                    artifact_path: member.source_snapshot.artifact_path.clone(),
2125                    source,
2126                }
2127            })?;
2128            checksum.verify(expected).map_err(|source| {
2129                RestoreApplyDryRunError::ArtifactChecksum {
2130                    source_canister: member.source_canister.clone(),
2131                    artifact_path: member.source_snapshot.artifact_path.clone(),
2132                    source,
2133                }
2134            })?;
2135            (Some(checksum.hash), true)
2136        } else {
2137            (None, false)
2138        };
2139
2140    Ok(RestoreApplyArtifactCheck {
2141        source_canister: member.source_canister.clone(),
2142        target_canister: member.target_canister.clone(),
2143        snapshot_id: member.source_snapshot.snapshot_id.clone(),
2144        artifact_path: member.source_snapshot.artifact_path.clone(),
2145        resolved_path: resolved_path.to_string_lossy().to_string(),
2146        exists: true,
2147        checksum_algorithm: member.source_snapshot.checksum_algorithm.clone(),
2148        checksum_expected: member.source_snapshot.checksum.clone(),
2149        checksum_actual,
2150        checksum_verified,
2151    })
2152}
2153
2154// Reject absolute paths and parent traversal before joining with the backup root.
2155fn safe_restore_artifact_path(
2156    source_canister: &str,
2157    artifact_path: &str,
2158) -> Result<PathBuf, RestoreApplyDryRunError> {
2159    let path = Path::new(artifact_path);
2160    let is_safe = path
2161        .components()
2162        .all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
2163
2164    if is_safe {
2165        return Ok(path.to_path_buf());
2166    }
2167
2168    Err(RestoreApplyDryRunError::ArtifactPathEscapesBackup {
2169        source_canister: source_canister.to_string(),
2170        artifact_path: artifact_path.to_string(),
2171    })
2172}
2173
2174// Validate that a supplied restore status belongs to the restore plan.
2175fn validate_restore_status_matches_plan(
2176    plan: &RestorePlan,
2177    status: &RestoreStatus,
2178) -> Result<(), RestoreApplyDryRunError> {
2179    validate_status_string_field("backup_id", &plan.backup_id, &status.backup_id)?;
2180    validate_status_string_field(
2181        "source_environment",
2182        &plan.source_environment,
2183        &status.source_environment,
2184    )?;
2185    validate_status_string_field(
2186        "source_root_canister",
2187        &plan.source_root_canister,
2188        &status.source_root_canister,
2189    )?;
2190    validate_status_string_field("topology_hash", &plan.topology_hash, &status.topology_hash)?;
2191    validate_status_usize_field("member_count", plan.member_count, status.member_count)?;
2192    validate_status_usize_field(
2193        "phase_count",
2194        plan.ordering_summary.phase_count,
2195        status.phase_count,
2196    )?;
2197    Ok(())
2198}
2199
2200// Validate one string field shared by restore plan and status.
2201fn validate_status_string_field(
2202    field: &'static str,
2203    plan: &str,
2204    status: &str,
2205) -> Result<(), RestoreApplyDryRunError> {
2206    if plan == status {
2207        return Ok(());
2208    }
2209
2210    Err(RestoreApplyDryRunError::StatusPlanMismatch {
2211        field,
2212        plan: plan.to_string(),
2213        status: status.to_string(),
2214    })
2215}
2216
2217// Validate one numeric field shared by restore plan and status.
2218const fn validate_status_usize_field(
2219    field: &'static str,
2220    plan: usize,
2221    status: usize,
2222) -> Result<(), RestoreApplyDryRunError> {
2223    if plan == status {
2224        return Ok(());
2225    }
2226
2227    Err(RestoreApplyDryRunError::StatusPlanCountMismatch {
2228        field,
2229        plan,
2230        status,
2231    })
2232}
2233
2234///
2235/// RestoreApplyArtifactValidation
2236///
2237
2238#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2239pub struct RestoreApplyArtifactValidation {
2240    pub backup_root: String,
2241    pub checked_members: usize,
2242    pub artifacts_present: bool,
2243    pub checksums_verified: bool,
2244    pub members_with_expected_checksums: usize,
2245    pub checks: Vec<RestoreApplyArtifactCheck>,
2246}
2247
2248///
2249/// RestoreApplyArtifactCheck
2250///
2251
2252#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2253pub struct RestoreApplyArtifactCheck {
2254    pub source_canister: String,
2255    pub target_canister: String,
2256    pub snapshot_id: String,
2257    pub artifact_path: String,
2258    pub resolved_path: String,
2259    pub exists: bool,
2260    pub checksum_algorithm: String,
2261    pub checksum_expected: Option<String>,
2262    pub checksum_actual: Option<String>,
2263    pub checksum_verified: bool,
2264}
2265
2266///
2267/// RestoreApplyDryRunPhase
2268///
2269
2270#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2271pub struct RestoreApplyDryRunPhase {
2272    pub restore_group: u16,
2273    pub operations: Vec<RestoreApplyDryRunOperation>,
2274}
2275
2276impl RestoreApplyDryRunPhase {
2277    // Build one dry-run phase from one restore plan phase.
2278    fn from_plan_phase(phase: &RestorePhase, next_sequence: &mut usize) -> Self {
2279        let mut operations = Vec::new();
2280
2281        for member in &phase.members {
2282            push_member_operation(
2283                &mut operations,
2284                next_sequence,
2285                RestoreApplyOperationKind::UploadSnapshot,
2286                member,
2287                None,
2288            );
2289            push_member_operation(
2290                &mut operations,
2291                next_sequence,
2292                RestoreApplyOperationKind::LoadSnapshot,
2293                member,
2294                None,
2295            );
2296
2297            for check in &member.verification_checks {
2298                push_member_operation(
2299                    &mut operations,
2300                    next_sequence,
2301                    RestoreApplyOperationKind::VerifyMember,
2302                    member,
2303                    Some(check),
2304                );
2305            }
2306        }
2307
2308        Self {
2309            restore_group: phase.restore_group,
2310            operations,
2311        }
2312    }
2313}
2314
2315// Append one member-level dry-run operation using the current phase order.
2316fn push_member_operation(
2317    operations: &mut Vec<RestoreApplyDryRunOperation>,
2318    next_sequence: &mut usize,
2319    operation: RestoreApplyOperationKind,
2320    member: &RestorePlanMember,
2321    check: Option<&VerificationCheck>,
2322) {
2323    let sequence = *next_sequence;
2324    *next_sequence += 1;
2325
2326    operations.push(RestoreApplyDryRunOperation {
2327        sequence,
2328        operation,
2329        restore_group: member.restore_group,
2330        phase_order: member.phase_order,
2331        source_canister: member.source_canister.clone(),
2332        target_canister: member.target_canister.clone(),
2333        role: member.role.clone(),
2334        snapshot_id: Some(member.source_snapshot.snapshot_id.clone()),
2335        artifact_path: Some(member.source_snapshot.artifact_path.clone()),
2336        verification_kind: check.map(|check| check.kind.clone()),
2337        verification_method: check.and_then(|check| check.method.clone()),
2338    });
2339}
2340
2341// Append fleet-level verification checks after all member operations.
2342fn append_fleet_verification_operations(
2343    plan: &RestorePlan,
2344    phases: &mut [RestoreApplyDryRunPhase],
2345    next_sequence: &mut usize,
2346) {
2347    if plan.fleet_verification_checks.is_empty() {
2348        return;
2349    }
2350
2351    let Some(phase) = phases.last_mut() else {
2352        return;
2353    };
2354    let root = plan
2355        .phases
2356        .iter()
2357        .flat_map(|phase| phase.members.iter())
2358        .find(|member| member.source_canister == plan.source_root_canister);
2359    let source_canister = root.map_or_else(
2360        || plan.source_root_canister.clone(),
2361        |member| member.source_canister.clone(),
2362    );
2363    let target_canister = root.map_or_else(
2364        || plan.source_root_canister.clone(),
2365        |member| member.target_canister.clone(),
2366    );
2367    let restore_group = phase.restore_group;
2368
2369    for check in &plan.fleet_verification_checks {
2370        push_fleet_operation(
2371            &mut phase.operations,
2372            next_sequence,
2373            restore_group,
2374            &source_canister,
2375            &target_canister,
2376            check,
2377        );
2378    }
2379}
2380
2381// Append one fleet-level dry-run verification operation.
2382fn push_fleet_operation(
2383    operations: &mut Vec<RestoreApplyDryRunOperation>,
2384    next_sequence: &mut usize,
2385    restore_group: u16,
2386    source_canister: &str,
2387    target_canister: &str,
2388    check: &VerificationCheck,
2389) {
2390    let sequence = *next_sequence;
2391    *next_sequence += 1;
2392    let phase_order = operations.len();
2393
2394    operations.push(RestoreApplyDryRunOperation {
2395        sequence,
2396        operation: RestoreApplyOperationKind::VerifyFleet,
2397        restore_group,
2398        phase_order,
2399        source_canister: source_canister.to_string(),
2400        target_canister: target_canister.to_string(),
2401        role: "fleet".to_string(),
2402        snapshot_id: None,
2403        artifact_path: None,
2404        verification_kind: Some(check.kind.clone()),
2405        verification_method: check.method.clone(),
2406    });
2407}
2408
2409///
2410/// RestoreApplyDryRunOperation
2411///
2412
2413#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2414pub struct RestoreApplyDryRunOperation {
2415    pub sequence: usize,
2416    pub operation: RestoreApplyOperationKind,
2417    pub restore_group: u16,
2418    pub phase_order: usize,
2419    pub source_canister: String,
2420    pub target_canister: String,
2421    pub role: String,
2422    pub snapshot_id: Option<String>,
2423    pub artifact_path: Option<String>,
2424    pub verification_kind: Option<String>,
2425    pub verification_method: Option<String>,
2426}
2427
2428///
2429/// RestoreApplyOperationKind
2430///
2431
2432#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2433#[serde(rename_all = "kebab-case")]
2434pub enum RestoreApplyOperationKind {
2435    UploadSnapshot,
2436    LoadSnapshot,
2437    ReinstallCode,
2438    VerifyMember,
2439    VerifyFleet,
2440}
2441
2442///
2443/// RestoreApplyDryRunError
2444///
2445
2446#[derive(Debug, ThisError)]
2447pub enum RestoreApplyDryRunError {
2448    #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
2449    StatusPlanMismatch {
2450        field: &'static str,
2451        plan: String,
2452        status: String,
2453    },
2454
2455    #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
2456    StatusPlanCountMismatch {
2457        field: &'static str,
2458        plan: usize,
2459        status: usize,
2460    },
2461
2462    #[error("restore artifact path for {source_canister} escapes backup root: {artifact_path}")]
2463    ArtifactPathEscapesBackup {
2464        source_canister: String,
2465        artifact_path: String,
2466    },
2467
2468    #[error(
2469        "restore artifact for {source_canister} is missing: {artifact_path} at {resolved_path}"
2470    )]
2471    ArtifactMissing {
2472        source_canister: String,
2473        artifact_path: String,
2474        resolved_path: String,
2475    },
2476
2477    #[error("restore artifact checksum failed for {source_canister} at {artifact_path}: {source}")]
2478    ArtifactChecksum {
2479        source_canister: String,
2480        artifact_path: String,
2481        #[source]
2482        source: ArtifactChecksumError,
2483    },
2484}
2485
2486///
2487/// RestoreIdentitySummary
2488///
2489
2490#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2491pub struct RestoreIdentitySummary {
2492    pub mapping_supplied: bool,
2493    pub all_sources_mapped: bool,
2494    pub fixed_members: usize,
2495    pub relocatable_members: usize,
2496    pub in_place_members: usize,
2497    pub mapped_members: usize,
2498    pub remapped_members: usize,
2499}
2500
2501///
2502/// RestoreSnapshotSummary
2503///
2504
2505#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2506#[expect(
2507    clippy::struct_excessive_bools,
2508    reason = "restore summaries intentionally expose machine-readable readiness flags"
2509)]
2510pub struct RestoreSnapshotSummary {
2511    pub all_members_have_module_hash: bool,
2512    pub all_members_have_wasm_hash: bool,
2513    pub all_members_have_code_version: bool,
2514    pub all_members_have_checksum: bool,
2515    pub members_with_module_hash: usize,
2516    pub members_with_wasm_hash: usize,
2517    pub members_with_code_version: usize,
2518    pub members_with_checksum: usize,
2519}
2520
2521///
2522/// RestoreVerificationSummary
2523///
2524
2525#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2526pub struct RestoreVerificationSummary {
2527    pub verification_required: bool,
2528    pub all_members_have_checks: bool,
2529    pub fleet_checks: usize,
2530    pub member_check_groups: usize,
2531    pub member_checks: usize,
2532    pub members_with_checks: usize,
2533    pub total_checks: usize,
2534}
2535
2536///
2537/// RestoreReadinessSummary
2538///
2539
2540#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2541pub struct RestoreReadinessSummary {
2542    pub ready: bool,
2543    pub reasons: Vec<String>,
2544}
2545
2546///
2547/// RestoreOperationSummary
2548///
2549
2550#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2551pub struct RestoreOperationSummary {
2552    #[serde(default)]
2553    pub planned_snapshot_uploads: usize,
2554    pub planned_snapshot_loads: usize,
2555    pub planned_code_reinstalls: usize,
2556    pub planned_verification_checks: usize,
2557    #[serde(default)]
2558    pub planned_operations: usize,
2559    pub planned_phases: usize,
2560}
2561
2562impl RestoreOperationSummary {
2563    /// Return planned snapshot uploads, deriving the value for older plan JSON.
2564    #[must_use]
2565    pub const fn effective_planned_snapshot_uploads(&self, member_count: usize) -> usize {
2566        if self.planned_snapshot_uploads == 0 && member_count > 0 {
2567            return member_count;
2568        }
2569
2570        self.planned_snapshot_uploads
2571    }
2572
2573    /// Return total planned operations, deriving the value for older plan JSON.
2574    #[must_use]
2575    pub const fn effective_planned_operations(&self, member_count: usize) -> usize {
2576        if self.planned_operations == 0 {
2577            return self.effective_planned_snapshot_uploads(member_count)
2578                + self.planned_snapshot_loads
2579                + self.planned_code_reinstalls
2580                + self.planned_verification_checks;
2581        }
2582
2583        self.planned_operations
2584    }
2585}
2586
2587///
2588/// RestoreOrderingSummary
2589///
2590
2591#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2592pub struct RestoreOrderingSummary {
2593    pub phase_count: usize,
2594    pub dependency_free_members: usize,
2595    pub in_group_parent_edges: usize,
2596    pub cross_group_parent_edges: usize,
2597}
2598
2599///
2600/// RestorePhase
2601///
2602
2603#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2604pub struct RestorePhase {
2605    pub restore_group: u16,
2606    pub members: Vec<RestorePlanMember>,
2607}
2608
2609///
2610/// RestorePlanMember
2611///
2612
2613#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2614pub struct RestorePlanMember {
2615    pub source_canister: String,
2616    pub target_canister: String,
2617    pub role: String,
2618    pub parent_source_canister: Option<String>,
2619    pub parent_target_canister: Option<String>,
2620    pub ordering_dependency: Option<RestoreOrderingDependency>,
2621    pub phase_order: usize,
2622    pub restore_group: u16,
2623    pub identity_mode: IdentityMode,
2624    pub verification_class: String,
2625    pub verification_checks: Vec<VerificationCheck>,
2626    pub source_snapshot: SourceSnapshot,
2627}
2628
2629///
2630/// RestoreOrderingDependency
2631///
2632
2633#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2634pub struct RestoreOrderingDependency {
2635    pub source_canister: String,
2636    pub target_canister: String,
2637    pub relationship: RestoreOrderingRelationship,
2638}
2639
2640///
2641/// RestoreOrderingRelationship
2642///
2643
2644#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2645#[serde(rename_all = "kebab-case")]
2646pub enum RestoreOrderingRelationship {
2647    ParentInSameGroup,
2648    ParentInEarlierGroup,
2649}
2650
2651///
2652/// RestorePlanner
2653///
2654
2655pub struct RestorePlanner;
2656
2657impl RestorePlanner {
2658    /// Build a no-mutation restore plan from the manifest and optional target mapping.
2659    pub fn plan(
2660        manifest: &FleetBackupManifest,
2661        mapping: Option<&RestoreMapping>,
2662    ) -> Result<RestorePlan, RestorePlanError> {
2663        manifest.validate()?;
2664        if let Some(mapping) = mapping {
2665            validate_mapping(mapping)?;
2666            validate_mapping_sources(manifest, mapping)?;
2667        }
2668
2669        let members = resolve_members(manifest, mapping)?;
2670        let identity_summary = restore_identity_summary(&members, mapping.is_some());
2671        let snapshot_summary = restore_snapshot_summary(&members);
2672        let verification_summary = restore_verification_summary(manifest, &members);
2673        let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
2674        validate_restore_group_dependencies(&members)?;
2675        let phases = group_and_order_members(members)?;
2676        let ordering_summary = restore_ordering_summary(&phases);
2677        let operation_summary =
2678            restore_operation_summary(manifest.fleet.members.len(), &verification_summary, &phases);
2679
2680        Ok(RestorePlan {
2681            backup_id: manifest.backup_id.clone(),
2682            source_environment: manifest.source.environment.clone(),
2683            source_root_canister: manifest.source.root_canister.clone(),
2684            topology_hash: manifest.fleet.topology_hash.clone(),
2685            member_count: manifest.fleet.members.len(),
2686            identity_summary,
2687            snapshot_summary,
2688            verification_summary,
2689            readiness_summary,
2690            operation_summary,
2691            ordering_summary,
2692            design_conformance: Some(manifest.design_conformance_report()),
2693            fleet_verification_checks: manifest.verification.fleet_checks.clone(),
2694            phases,
2695        })
2696    }
2697}
2698
2699///
2700/// RestorePlanError
2701///
2702
2703#[derive(Debug, ThisError)]
2704pub enum RestorePlanError {
2705    #[error(transparent)]
2706    InvalidManifest(#[from] ManifestValidationError),
2707
2708    #[error("field {field} must be a valid principal: {value}")]
2709    InvalidPrincipal { field: &'static str, value: String },
2710
2711    #[error("mapping contains duplicate source canister {0}")]
2712    DuplicateMappingSource(String),
2713
2714    #[error("mapping contains duplicate target canister {0}")]
2715    DuplicateMappingTarget(String),
2716
2717    #[error("mapping references unknown source canister {0}")]
2718    UnknownMappingSource(String),
2719
2720    #[error("mapping is missing source canister {0}")]
2721    MissingMappingSource(String),
2722
2723    #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
2724    FixedIdentityRemap {
2725        source_canister: String,
2726        target_canister: String,
2727    },
2728
2729    #[error("restore plan contains duplicate target canister {0}")]
2730    DuplicatePlanTarget(String),
2731
2732    #[error("restore group {0} contains a parent cycle or unresolved dependency")]
2733    RestoreOrderCycle(u16),
2734
2735    #[error(
2736        "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
2737    )]
2738    ParentRestoreGroupAfterChild {
2739        child_source_canister: String,
2740        parent_source_canister: String,
2741        child_restore_group: u16,
2742        parent_restore_group: u16,
2743    },
2744}
2745
2746// Validate a user-supplied restore mapping before applying it to the manifest.
2747fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
2748    let mut sources = BTreeSet::new();
2749    let mut targets = BTreeSet::new();
2750
2751    for entry in &mapping.members {
2752        validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
2753        validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
2754
2755        if !sources.insert(entry.source_canister.clone()) {
2756            return Err(RestorePlanError::DuplicateMappingSource(
2757                entry.source_canister.clone(),
2758            ));
2759        }
2760
2761        if !targets.insert(entry.target_canister.clone()) {
2762            return Err(RestorePlanError::DuplicateMappingTarget(
2763                entry.target_canister.clone(),
2764            ));
2765        }
2766    }
2767
2768    Ok(())
2769}
2770
2771// Ensure mappings only reference members declared in the manifest.
2772fn validate_mapping_sources(
2773    manifest: &FleetBackupManifest,
2774    mapping: &RestoreMapping,
2775) -> Result<(), RestorePlanError> {
2776    let sources = manifest
2777        .fleet
2778        .members
2779        .iter()
2780        .map(|member| member.canister_id.as_str())
2781        .collect::<BTreeSet<_>>();
2782
2783    for entry in &mapping.members {
2784        if !sources.contains(entry.source_canister.as_str()) {
2785            return Err(RestorePlanError::UnknownMappingSource(
2786                entry.source_canister.clone(),
2787            ));
2788        }
2789    }
2790
2791    Ok(())
2792}
2793
2794// Resolve source manifest members into target restore members.
2795fn resolve_members(
2796    manifest: &FleetBackupManifest,
2797    mapping: Option<&RestoreMapping>,
2798) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
2799    let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
2800    let mut targets = BTreeSet::new();
2801    let mut source_to_target = BTreeMap::new();
2802
2803    for member in &manifest.fleet.members {
2804        let target = resolve_target(member, mapping)?;
2805        if !targets.insert(target.clone()) {
2806            return Err(RestorePlanError::DuplicatePlanTarget(target));
2807        }
2808
2809        source_to_target.insert(member.canister_id.clone(), target.clone());
2810        plan_members.push(RestorePlanMember {
2811            source_canister: member.canister_id.clone(),
2812            target_canister: target,
2813            role: member.role.clone(),
2814            parent_source_canister: member.parent_canister_id.clone(),
2815            parent_target_canister: None,
2816            ordering_dependency: None,
2817            phase_order: 0,
2818            restore_group: member.restore_group,
2819            identity_mode: member.identity_mode.clone(),
2820            verification_class: member.verification_class.clone(),
2821            verification_checks: concrete_member_verification_checks(
2822                member,
2823                &manifest.verification,
2824            ),
2825            source_snapshot: member.source_snapshot.clone(),
2826        });
2827    }
2828
2829    for member in &mut plan_members {
2830        member.parent_target_canister = member
2831            .parent_source_canister
2832            .as_ref()
2833            .and_then(|parent| source_to_target.get(parent))
2834            .cloned();
2835    }
2836
2837    Ok(plan_members)
2838}
2839
2840// Resolve all concrete verification checks that apply to one restore member role.
2841fn concrete_member_verification_checks(
2842    member: &FleetMember,
2843    verification: &VerificationPlan,
2844) -> Vec<VerificationCheck> {
2845    let mut checks = member
2846        .verification_checks
2847        .iter()
2848        .filter(|check| verification_check_applies_to_role(check, &member.role))
2849        .cloned()
2850        .collect::<Vec<_>>();
2851
2852    for group in &verification.member_checks {
2853        if group.role != member.role {
2854            continue;
2855        }
2856
2857        checks.extend(
2858            group
2859                .checks
2860                .iter()
2861                .filter(|check| verification_check_applies_to_role(check, &member.role))
2862                .cloned(),
2863        );
2864    }
2865
2866    checks
2867}
2868
2869// Return whether a verification check's role filter includes one member role.
2870fn verification_check_applies_to_role(check: &VerificationCheck, role: &str) -> bool {
2871    check.roles.is_empty() || check.roles.iter().any(|check_role| check_role == role)
2872}
2873
2874// Resolve one member's target canister, enforcing identity continuity.
2875fn resolve_target(
2876    member: &FleetMember,
2877    mapping: Option<&RestoreMapping>,
2878) -> Result<String, RestorePlanError> {
2879    let target = match mapping {
2880        Some(mapping) => mapping
2881            .target_for(&member.canister_id)
2882            .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
2883            .to_string(),
2884        None => member.canister_id.clone(),
2885    };
2886
2887    if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
2888        return Err(RestorePlanError::FixedIdentityRemap {
2889            source_canister: member.canister_id.clone(),
2890            target_canister: target,
2891        });
2892    }
2893
2894    Ok(target)
2895}
2896
2897// Summarize identity and mapping decisions before grouping restore phases.
2898fn restore_identity_summary(
2899    members: &[RestorePlanMember],
2900    mapping_supplied: bool,
2901) -> RestoreIdentitySummary {
2902    let mut summary = RestoreIdentitySummary {
2903        mapping_supplied,
2904        all_sources_mapped: false,
2905        fixed_members: 0,
2906        relocatable_members: 0,
2907        in_place_members: 0,
2908        mapped_members: 0,
2909        remapped_members: 0,
2910    };
2911
2912    for member in members {
2913        match member.identity_mode {
2914            IdentityMode::Fixed => summary.fixed_members += 1,
2915            IdentityMode::Relocatable => summary.relocatable_members += 1,
2916        }
2917
2918        if member.source_canister == member.target_canister {
2919            summary.in_place_members += 1;
2920        } else {
2921            summary.remapped_members += 1;
2922        }
2923        if mapping_supplied {
2924            summary.mapped_members += 1;
2925        }
2926    }
2927
2928    summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
2929
2930    summary
2931}
2932
2933// Summarize snapshot provenance completeness before grouping restore phases.
2934fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
2935    let members_with_module_hash = members
2936        .iter()
2937        .filter(|member| member.source_snapshot.module_hash.is_some())
2938        .count();
2939    let members_with_wasm_hash = members
2940        .iter()
2941        .filter(|member| member.source_snapshot.wasm_hash.is_some())
2942        .count();
2943    let members_with_code_version = members
2944        .iter()
2945        .filter(|member| member.source_snapshot.code_version.is_some())
2946        .count();
2947    let members_with_checksum = members
2948        .iter()
2949        .filter(|member| member.source_snapshot.checksum.is_some())
2950        .count();
2951
2952    RestoreSnapshotSummary {
2953        all_members_have_module_hash: members_with_module_hash == members.len(),
2954        all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
2955        all_members_have_code_version: members_with_code_version == members.len(),
2956        all_members_have_checksum: members_with_checksum == members.len(),
2957        members_with_module_hash,
2958        members_with_wasm_hash,
2959        members_with_code_version,
2960        members_with_checksum,
2961    }
2962}
2963
2964// Summarize whether restore planning has the metadata required for automation.
2965fn restore_readiness_summary(
2966    snapshot: &RestoreSnapshotSummary,
2967    verification: &RestoreVerificationSummary,
2968) -> RestoreReadinessSummary {
2969    let mut reasons = Vec::new();
2970
2971    if !snapshot.all_members_have_module_hash {
2972        reasons.push("missing-module-hash".to_string());
2973    }
2974    if !snapshot.all_members_have_wasm_hash {
2975        reasons.push("missing-wasm-hash".to_string());
2976    }
2977    if !snapshot.all_members_have_code_version {
2978        reasons.push("missing-code-version".to_string());
2979    }
2980    if !snapshot.all_members_have_checksum {
2981        reasons.push("missing-snapshot-checksum".to_string());
2982    }
2983    if !verification.all_members_have_checks {
2984        reasons.push("missing-verification-checks".to_string());
2985    }
2986
2987    RestoreReadinessSummary {
2988        ready: reasons.is_empty(),
2989        reasons,
2990    }
2991}
2992
2993// Summarize restore verification work declared by the manifest and members.
2994fn restore_verification_summary(
2995    manifest: &FleetBackupManifest,
2996    members: &[RestorePlanMember],
2997) -> RestoreVerificationSummary {
2998    let fleet_checks = manifest.verification.fleet_checks.len();
2999    let member_check_groups = manifest.verification.member_checks.len();
3000    let member_checks = members
3001        .iter()
3002        .map(|member| member.verification_checks.len())
3003        .sum::<usize>();
3004    let members_with_checks = members
3005        .iter()
3006        .filter(|member| !member.verification_checks.is_empty())
3007        .count();
3008
3009    RestoreVerificationSummary {
3010        verification_required: true,
3011        all_members_have_checks: members_with_checks == members.len(),
3012        fleet_checks,
3013        member_check_groups,
3014        member_checks,
3015        members_with_checks,
3016        total_checks: fleet_checks + member_checks,
3017    }
3018}
3019
3020// Summarize the concrete restore operations implied by a no-mutation plan.
3021const fn restore_operation_summary(
3022    member_count: usize,
3023    verification_summary: &RestoreVerificationSummary,
3024    phases: &[RestorePhase],
3025) -> RestoreOperationSummary {
3026    RestoreOperationSummary {
3027        planned_snapshot_uploads: member_count,
3028        planned_snapshot_loads: member_count,
3029        planned_code_reinstalls: 0,
3030        planned_verification_checks: verification_summary.total_checks,
3031        planned_operations: member_count + member_count + verification_summary.total_checks,
3032        planned_phases: phases.len(),
3033    }
3034}
3035
3036// Reject group assignments that would restore a child before its parent.
3037fn validate_restore_group_dependencies(
3038    members: &[RestorePlanMember],
3039) -> Result<(), RestorePlanError> {
3040    let groups_by_source = members
3041        .iter()
3042        .map(|member| (member.source_canister.as_str(), member.restore_group))
3043        .collect::<BTreeMap<_, _>>();
3044
3045    for member in members {
3046        let Some(parent) = &member.parent_source_canister else {
3047            continue;
3048        };
3049        let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
3050            continue;
3051        };
3052
3053        if *parent_group > member.restore_group {
3054            return Err(RestorePlanError::ParentRestoreGroupAfterChild {
3055                child_source_canister: member.source_canister.clone(),
3056                parent_source_canister: parent.clone(),
3057                child_restore_group: member.restore_group,
3058                parent_restore_group: *parent_group,
3059            });
3060        }
3061    }
3062
3063    Ok(())
3064}
3065
3066// Group members and apply parent-before-child ordering inside each group.
3067fn group_and_order_members(
3068    members: Vec<RestorePlanMember>,
3069) -> Result<Vec<RestorePhase>, RestorePlanError> {
3070    let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
3071    for member in members {
3072        groups.entry(member.restore_group).or_default().push(member);
3073    }
3074
3075    groups
3076        .into_iter()
3077        .map(|(restore_group, members)| {
3078            let members = order_group(restore_group, members)?;
3079            Ok(RestorePhase {
3080                restore_group,
3081                members,
3082            })
3083        })
3084        .collect()
3085}
3086
3087// Topologically order one group using manifest parent relationships.
3088fn order_group(
3089    restore_group: u16,
3090    members: Vec<RestorePlanMember>,
3091) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
3092    let mut remaining = members;
3093    let group_sources = remaining
3094        .iter()
3095        .map(|member| member.source_canister.clone())
3096        .collect::<BTreeSet<_>>();
3097    let mut emitted = BTreeSet::new();
3098    let mut ordered = Vec::with_capacity(remaining.len());
3099
3100    while !remaining.is_empty() {
3101        let Some(index) = remaining
3102            .iter()
3103            .position(|member| parent_satisfied(member, &group_sources, &emitted))
3104        else {
3105            return Err(RestorePlanError::RestoreOrderCycle(restore_group));
3106        };
3107
3108        let mut member = remaining.remove(index);
3109        member.phase_order = ordered.len();
3110        member.ordering_dependency = ordering_dependency(&member, &group_sources);
3111        emitted.insert(member.source_canister.clone());
3112        ordered.push(member);
3113    }
3114
3115    Ok(ordered)
3116}
3117
3118// Describe the topology dependency that controlled a member's restore ordering.
3119fn ordering_dependency(
3120    member: &RestorePlanMember,
3121    group_sources: &BTreeSet<String>,
3122) -> Option<RestoreOrderingDependency> {
3123    let parent_source = member.parent_source_canister.as_ref()?;
3124    let parent_target = member.parent_target_canister.as_ref()?;
3125    let relationship = if group_sources.contains(parent_source) {
3126        RestoreOrderingRelationship::ParentInSameGroup
3127    } else {
3128        RestoreOrderingRelationship::ParentInEarlierGroup
3129    };
3130
3131    Some(RestoreOrderingDependency {
3132        source_canister: parent_source.clone(),
3133        target_canister: parent_target.clone(),
3134        relationship,
3135    })
3136}
3137
3138// Summarize the dependency ordering metadata exposed in the restore plan.
3139fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
3140    let mut summary = RestoreOrderingSummary {
3141        phase_count: phases.len(),
3142        dependency_free_members: 0,
3143        in_group_parent_edges: 0,
3144        cross_group_parent_edges: 0,
3145    };
3146
3147    for member in phases.iter().flat_map(|phase| phase.members.iter()) {
3148        match &member.ordering_dependency {
3149            Some(dependency)
3150                if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
3151            {
3152                summary.in_group_parent_edges += 1;
3153            }
3154            Some(dependency)
3155                if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
3156            {
3157                summary.cross_group_parent_edges += 1;
3158            }
3159            Some(_) => {}
3160            None => summary.dependency_free_members += 1,
3161        }
3162    }
3163
3164    summary
3165}
3166
3167// Determine whether a member's in-group parent has already been emitted.
3168fn parent_satisfied(
3169    member: &RestorePlanMember,
3170    group_sources: &BTreeSet<String>,
3171    emitted: &BTreeSet<String>,
3172) -> bool {
3173    match &member.parent_source_canister {
3174        Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
3175        _ => true,
3176    }
3177}
3178
3179// Validate textual principal fields used in mappings.
3180fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
3181    Principal::from_str(value)
3182        .map(|_| ())
3183        .map_err(|_| RestorePlanError::InvalidPrincipal {
3184            field,
3185            value: value.to_string(),
3186        })
3187}
3188
3189#[cfg(test)]
3190mod tests;