Skip to main content

canic_backup/restore/
mod.rs

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