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    /// Render the next ready operation as a compact runner-facing response.
401    #[must_use]
402    pub fn next_operation(&self) -> RestoreApplyNextOperation {
403        RestoreApplyNextOperation::from_journal(self)
404    }
405
406    /// Mark one restore apply operation completed and refresh journal counts.
407    pub fn mark_operation_completed(
408        &mut self,
409        sequence: usize,
410    ) -> Result<(), RestoreApplyJournalError> {
411        self.transition_operation(sequence, RestoreApplyOperationState::Completed, Vec::new())
412    }
413
414    /// Mark one restore apply operation failed and refresh journal counts.
415    pub fn mark_operation_failed(
416        &mut self,
417        sequence: usize,
418        reason: String,
419    ) -> Result<(), RestoreApplyJournalError> {
420        if reason.trim().is_empty() {
421            return Err(RestoreApplyJournalError::FailureReasonRequired(sequence));
422        }
423
424        self.transition_operation(sequence, RestoreApplyOperationState::Failed, vec![reason])
425    }
426
427    // Apply one legal operation state transition and revalidate the journal.
428    fn transition_operation(
429        &mut self,
430        sequence: usize,
431        next_state: RestoreApplyOperationState,
432        blocking_reasons: Vec<String>,
433    ) -> Result<(), RestoreApplyJournalError> {
434        let operation = self
435            .operations
436            .iter_mut()
437            .find(|operation| operation.sequence == sequence)
438            .ok_or(RestoreApplyJournalError::OperationNotFound(sequence))?;
439
440        if !operation.can_transition_to(&next_state) {
441            return Err(RestoreApplyJournalError::InvalidOperationTransition {
442                sequence,
443                from: operation.state.clone(),
444                to: next_state,
445            });
446        }
447
448        operation.state = next_state;
449        operation.blocking_reasons = blocking_reasons;
450        self.refresh_operation_counts();
451        self.validate()
452    }
453
454    // Recompute operation counts after a journal operation state change.
455    fn refresh_operation_counts(&mut self) {
456        let state_counts = RestoreApplyJournalStateCounts::from_operations(&self.operations);
457        self.operation_count = self.operations.len();
458        self.pending_operations = state_counts.pending;
459        self.ready_operations = state_counts.ready;
460        self.blocked_operations = state_counts.blocked;
461        self.completed_operations = state_counts.completed;
462        self.failed_operations = state_counts.failed;
463    }
464}
465
466// Validate the supported restore apply journal format version.
467const fn validate_apply_journal_version(version: u16) -> Result<(), RestoreApplyJournalError> {
468    if version == 1 {
469        return Ok(());
470    }
471
472    Err(RestoreApplyJournalError::UnsupportedVersion(version))
473}
474
475// Validate required nonempty restore apply journal fields.
476fn validate_apply_journal_nonempty(
477    field: &'static str,
478    value: &str,
479) -> Result<(), RestoreApplyJournalError> {
480    if !value.trim().is_empty() {
481        return Ok(());
482    }
483
484    Err(RestoreApplyJournalError::MissingField(field))
485}
486
487// Validate one reported restore apply journal count.
488const fn validate_apply_journal_count(
489    field: &'static str,
490    reported: usize,
491    actual: usize,
492) -> Result<(), RestoreApplyJournalError> {
493    if reported == actual {
494        return Ok(());
495    }
496
497    Err(RestoreApplyJournalError::CountMismatch {
498        field,
499        reported,
500        actual,
501    })
502}
503
504// Validate operation sequence values are unique and contiguous from zero.
505fn validate_apply_journal_sequences(
506    operations: &[RestoreApplyJournalOperation],
507) -> Result<(), RestoreApplyJournalError> {
508    let mut sequences = BTreeSet::new();
509    for operation in operations {
510        if !sequences.insert(operation.sequence) {
511            return Err(RestoreApplyJournalError::DuplicateSequence(
512                operation.sequence,
513            ));
514        }
515    }
516
517    for expected in 0..operations.len() {
518        if !sequences.contains(&expected) {
519            return Err(RestoreApplyJournalError::MissingSequence(expected));
520        }
521    }
522
523    Ok(())
524}
525
526///
527/// RestoreApplyJournalStateCounts
528///
529
530#[derive(Clone, Debug, Default, Eq, PartialEq)]
531struct RestoreApplyJournalStateCounts {
532    pending: usize,
533    ready: usize,
534    blocked: usize,
535    completed: usize,
536    failed: usize,
537}
538
539impl RestoreApplyJournalStateCounts {
540    // Count operation states from concrete journal operation rows.
541    fn from_operations(operations: &[RestoreApplyJournalOperation]) -> Self {
542        let mut counts = Self::default();
543        for operation in operations {
544            match operation.state {
545                RestoreApplyOperationState::Pending => counts.pending += 1,
546                RestoreApplyOperationState::Ready => counts.ready += 1,
547                RestoreApplyOperationState::Blocked => counts.blocked += 1,
548                RestoreApplyOperationState::Completed => counts.completed += 1,
549                RestoreApplyOperationState::Failed => counts.failed += 1,
550            }
551        }
552        counts
553    }
554}
555
556// Explain why an apply journal is blocked before mutation is allowed.
557fn restore_apply_blocked_reasons(dry_run: &RestoreApplyDryRun) -> Vec<String> {
558    let mut reasons = dry_run.readiness_reasons.clone();
559
560    match &dry_run.artifact_validation {
561        Some(validation) => {
562            if !validation.artifacts_present {
563                reasons.push("missing-artifacts".to_string());
564            }
565            if !validation.checksums_verified {
566                reasons.push("artifact-checksum-validation-incomplete".to_string());
567            }
568        }
569        None => reasons.push("missing-artifact-validation".to_string()),
570    }
571
572    reasons.sort();
573    reasons.dedup();
574    reasons
575}
576
577///
578/// RestoreApplyJournalStatus
579///
580
581#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
582pub struct RestoreApplyJournalStatus {
583    pub status_version: u16,
584    pub backup_id: String,
585    pub ready: bool,
586    pub complete: bool,
587    pub blocked_reasons: Vec<String>,
588    pub operation_count: usize,
589    pub pending_operations: usize,
590    pub ready_operations: usize,
591    pub blocked_operations: usize,
592    pub completed_operations: usize,
593    pub failed_operations: usize,
594    pub next_ready_sequence: Option<usize>,
595    pub next_ready_operation: Option<RestoreApplyOperationKind>,
596}
597
598impl RestoreApplyJournalStatus {
599    /// Build a compact status projection from a restore apply journal.
600    #[must_use]
601    pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
602        let next_ready = journal.next_ready_operation();
603
604        Self {
605            status_version: 1,
606            backup_id: journal.backup_id.clone(),
607            ready: journal.ready,
608            complete: journal.operation_count > 0
609                && journal.completed_operations == journal.operation_count,
610            blocked_reasons: journal.blocked_reasons.clone(),
611            operation_count: journal.operation_count,
612            pending_operations: journal.pending_operations,
613            ready_operations: journal.ready_operations,
614            blocked_operations: journal.blocked_operations,
615            completed_operations: journal.completed_operations,
616            failed_operations: journal.failed_operations,
617            next_ready_sequence: next_ready.map(|operation| operation.sequence),
618            next_ready_operation: next_ready.map(|operation| operation.operation.clone()),
619        }
620    }
621}
622
623///
624/// RestoreApplyNextOperation
625///
626
627#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
628pub struct RestoreApplyNextOperation {
629    pub response_version: u16,
630    pub backup_id: String,
631    pub ready: bool,
632    pub complete: bool,
633    pub operation_available: bool,
634    pub blocked_reasons: Vec<String>,
635    pub operation: Option<RestoreApplyJournalOperation>,
636}
637
638impl RestoreApplyNextOperation {
639    /// Build a compact next-operation response from a restore apply journal.
640    #[must_use]
641    pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
642        let complete =
643            journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
644        let operation = journal.next_ready_operation().cloned();
645
646        Self {
647            response_version: 1,
648            backup_id: journal.backup_id.clone(),
649            ready: journal.ready,
650            complete,
651            operation_available: operation.is_some(),
652            blocked_reasons: journal.blocked_reasons.clone(),
653            operation,
654        }
655    }
656}
657
658///
659/// RestoreApplyJournalOperation
660///
661
662#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
663pub struct RestoreApplyJournalOperation {
664    pub sequence: usize,
665    pub operation: RestoreApplyOperationKind,
666    pub state: RestoreApplyOperationState,
667    pub blocking_reasons: Vec<String>,
668    pub restore_group: u16,
669    pub phase_order: usize,
670    pub source_canister: String,
671    pub target_canister: String,
672    pub role: String,
673    pub snapshot_id: Option<String>,
674    pub artifact_path: Option<String>,
675    pub verification_kind: Option<String>,
676    pub verification_method: Option<String>,
677}
678
679impl RestoreApplyJournalOperation {
680    // Build one initial journal operation from the dry-run operation row.
681    fn from_dry_run_operation(
682        operation: &RestoreApplyDryRunOperation,
683        state: RestoreApplyOperationState,
684        blocked_reasons: &[String],
685    ) -> Self {
686        Self {
687            sequence: operation.sequence,
688            operation: operation.operation.clone(),
689            state: state.clone(),
690            blocking_reasons: if state == RestoreApplyOperationState::Blocked {
691                blocked_reasons.to_vec()
692            } else {
693                Vec::new()
694            },
695            restore_group: operation.restore_group,
696            phase_order: operation.phase_order,
697            source_canister: operation.source_canister.clone(),
698            target_canister: operation.target_canister.clone(),
699            role: operation.role.clone(),
700            snapshot_id: operation.snapshot_id.clone(),
701            artifact_path: operation.artifact_path.clone(),
702            verification_kind: operation.verification_kind.clone(),
703            verification_method: operation.verification_method.clone(),
704        }
705    }
706
707    // Validate one restore apply journal operation row.
708    fn validate(&self) -> Result<(), RestoreApplyJournalError> {
709        validate_apply_journal_nonempty("operations[].source_canister", &self.source_canister)?;
710        validate_apply_journal_nonempty("operations[].target_canister", &self.target_canister)?;
711        validate_apply_journal_nonempty("operations[].role", &self.role)?;
712
713        match self.state {
714            RestoreApplyOperationState::Blocked if self.blocking_reasons.is_empty() => Err(
715                RestoreApplyJournalError::BlockedOperationMissingReason(self.sequence),
716            ),
717            RestoreApplyOperationState::Failed if self.blocking_reasons.is_empty() => Err(
718                RestoreApplyJournalError::FailureReasonRequired(self.sequence),
719            ),
720            RestoreApplyOperationState::Pending
721            | RestoreApplyOperationState::Ready
722            | RestoreApplyOperationState::Completed
723                if !self.blocking_reasons.is_empty() =>
724            {
725                Err(RestoreApplyJournalError::UnblockedOperationHasReasons(
726                    self.sequence,
727                ))
728            }
729            RestoreApplyOperationState::Blocked
730            | RestoreApplyOperationState::Failed
731            | RestoreApplyOperationState::Pending
732            | RestoreApplyOperationState::Ready
733            | RestoreApplyOperationState::Completed => Ok(()),
734        }
735    }
736
737    // Decide whether an operation can move to the requested next state.
738    const fn can_transition_to(&self, next_state: &RestoreApplyOperationState) -> bool {
739        match (&self.state, next_state) {
740            (
741                RestoreApplyOperationState::Ready
742                | RestoreApplyOperationState::Pending
743                | RestoreApplyOperationState::Completed,
744                RestoreApplyOperationState::Completed,
745            )
746            | (
747                RestoreApplyOperationState::Ready
748                | RestoreApplyOperationState::Pending
749                | RestoreApplyOperationState::Failed,
750                RestoreApplyOperationState::Failed,
751            ) => true,
752            (
753                RestoreApplyOperationState::Blocked
754                | RestoreApplyOperationState::Completed
755                | RestoreApplyOperationState::Failed
756                | RestoreApplyOperationState::Pending
757                | RestoreApplyOperationState::Ready,
758                _,
759            ) => false,
760        }
761    }
762}
763
764///
765/// RestoreApplyOperationState
766///
767
768#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
769#[serde(rename_all = "kebab-case")]
770pub enum RestoreApplyOperationState {
771    Pending,
772    Ready,
773    Blocked,
774    Completed,
775    Failed,
776}
777
778///
779/// RestoreApplyJournalError
780///
781
782#[derive(Debug, ThisError)]
783pub enum RestoreApplyJournalError {
784    #[error("unsupported restore apply journal version {0}")]
785    UnsupportedVersion(u16),
786
787    #[error("restore apply journal field {0} is required")]
788    MissingField(&'static str),
789
790    #[error("restore apply journal count {field} mismatch: reported={reported}, actual={actual}")]
791    CountMismatch {
792        field: &'static str,
793        reported: usize,
794        actual: usize,
795    },
796
797    #[error("restore apply journal has duplicate operation sequence {0}")]
798    DuplicateSequence(usize),
799
800    #[error("restore apply journal is missing operation sequence {0}")]
801    MissingSequence(usize),
802
803    #[error("ready restore apply journal cannot include blocked reasons or blocked operations")]
804    ReadyJournalHasBlockingState,
805
806    #[error("blocked restore apply journal operation {0} is missing a blocking reason")]
807    BlockedOperationMissingReason(usize),
808
809    #[error("unblocked restore apply journal operation {0} cannot have blocking reasons")]
810    UnblockedOperationHasReasons(usize),
811
812    #[error("restore apply journal operation {0} was not found")]
813    OperationNotFound(usize),
814
815    #[error("restore apply journal operation {sequence} cannot transition from {from:?} to {to:?}")]
816    InvalidOperationTransition {
817        sequence: usize,
818        from: RestoreApplyOperationState,
819        to: RestoreApplyOperationState,
820    },
821
822    #[error("failed restore apply journal operation {0} requires a reason")]
823    FailureReasonRequired(usize),
824}
825
826// Verify every planned restore artifact against one backup directory root.
827fn validate_restore_apply_artifacts(
828    plan: &RestorePlan,
829    backup_root: &Path,
830) -> Result<RestoreApplyArtifactValidation, RestoreApplyDryRunError> {
831    let mut checks = Vec::new();
832
833    for member in plan.ordered_members() {
834        checks.push(validate_restore_apply_artifact(member, backup_root)?);
835    }
836
837    let members_with_expected_checksums = checks
838        .iter()
839        .filter(|check| check.checksum_expected.is_some())
840        .count();
841    let artifacts_present = checks.iter().all(|check| check.exists);
842    let checksums_verified = members_with_expected_checksums == plan.member_count
843        && checks.iter().all(|check| check.checksum_verified);
844
845    Ok(RestoreApplyArtifactValidation {
846        backup_root: backup_root.to_string_lossy().to_string(),
847        checked_members: checks.len(),
848        artifacts_present,
849        checksums_verified,
850        members_with_expected_checksums,
851        checks,
852    })
853}
854
855// Verify one planned restore artifact path and checksum.
856fn validate_restore_apply_artifact(
857    member: &RestorePlanMember,
858    backup_root: &Path,
859) -> Result<RestoreApplyArtifactCheck, RestoreApplyDryRunError> {
860    let artifact_path = safe_restore_artifact_path(
861        &member.source_canister,
862        &member.source_snapshot.artifact_path,
863    )?;
864    let resolved_path = backup_root.join(&artifact_path);
865
866    if !resolved_path.exists() {
867        return Err(RestoreApplyDryRunError::ArtifactMissing {
868            source_canister: member.source_canister.clone(),
869            artifact_path: member.source_snapshot.artifact_path.clone(),
870            resolved_path: resolved_path.to_string_lossy().to_string(),
871        });
872    }
873
874    let (checksum_actual, checksum_verified) =
875        if let Some(expected) = &member.source_snapshot.checksum {
876            let checksum = ArtifactChecksum::from_path(&resolved_path).map_err(|source| {
877                RestoreApplyDryRunError::ArtifactChecksum {
878                    source_canister: member.source_canister.clone(),
879                    artifact_path: member.source_snapshot.artifact_path.clone(),
880                    source,
881                }
882            })?;
883            checksum.verify(expected).map_err(|source| {
884                RestoreApplyDryRunError::ArtifactChecksum {
885                    source_canister: member.source_canister.clone(),
886                    artifact_path: member.source_snapshot.artifact_path.clone(),
887                    source,
888                }
889            })?;
890            (Some(checksum.hash), true)
891        } else {
892            (None, false)
893        };
894
895    Ok(RestoreApplyArtifactCheck {
896        source_canister: member.source_canister.clone(),
897        target_canister: member.target_canister.clone(),
898        snapshot_id: member.source_snapshot.snapshot_id.clone(),
899        artifact_path: member.source_snapshot.artifact_path.clone(),
900        resolved_path: resolved_path.to_string_lossy().to_string(),
901        exists: true,
902        checksum_algorithm: member.source_snapshot.checksum_algorithm.clone(),
903        checksum_expected: member.source_snapshot.checksum.clone(),
904        checksum_actual,
905        checksum_verified,
906    })
907}
908
909// Reject absolute paths and parent traversal before joining with the backup root.
910fn safe_restore_artifact_path(
911    source_canister: &str,
912    artifact_path: &str,
913) -> Result<PathBuf, RestoreApplyDryRunError> {
914    let path = Path::new(artifact_path);
915    let is_safe = path
916        .components()
917        .all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
918
919    if is_safe {
920        return Ok(path.to_path_buf());
921    }
922
923    Err(RestoreApplyDryRunError::ArtifactPathEscapesBackup {
924        source_canister: source_canister.to_string(),
925        artifact_path: artifact_path.to_string(),
926    })
927}
928
929// Validate that a supplied restore status belongs to the restore plan.
930fn validate_restore_status_matches_plan(
931    plan: &RestorePlan,
932    status: &RestoreStatus,
933) -> Result<(), RestoreApplyDryRunError> {
934    validate_status_string_field("backup_id", &plan.backup_id, &status.backup_id)?;
935    validate_status_string_field(
936        "source_environment",
937        &plan.source_environment,
938        &status.source_environment,
939    )?;
940    validate_status_string_field(
941        "source_root_canister",
942        &plan.source_root_canister,
943        &status.source_root_canister,
944    )?;
945    validate_status_string_field("topology_hash", &plan.topology_hash, &status.topology_hash)?;
946    validate_status_usize_field("member_count", plan.member_count, status.member_count)?;
947    validate_status_usize_field(
948        "phase_count",
949        plan.ordering_summary.phase_count,
950        status.phase_count,
951    )?;
952    Ok(())
953}
954
955// Validate one string field shared by restore plan and status.
956fn validate_status_string_field(
957    field: &'static str,
958    plan: &str,
959    status: &str,
960) -> Result<(), RestoreApplyDryRunError> {
961    if plan == status {
962        return Ok(());
963    }
964
965    Err(RestoreApplyDryRunError::StatusPlanMismatch {
966        field,
967        plan: plan.to_string(),
968        status: status.to_string(),
969    })
970}
971
972// Validate one numeric field shared by restore plan and status.
973const fn validate_status_usize_field(
974    field: &'static str,
975    plan: usize,
976    status: usize,
977) -> Result<(), RestoreApplyDryRunError> {
978    if plan == status {
979        return Ok(());
980    }
981
982    Err(RestoreApplyDryRunError::StatusPlanCountMismatch {
983        field,
984        plan,
985        status,
986    })
987}
988
989///
990/// RestoreApplyArtifactValidation
991///
992
993#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
994pub struct RestoreApplyArtifactValidation {
995    pub backup_root: String,
996    pub checked_members: usize,
997    pub artifacts_present: bool,
998    pub checksums_verified: bool,
999    pub members_with_expected_checksums: usize,
1000    pub checks: Vec<RestoreApplyArtifactCheck>,
1001}
1002
1003///
1004/// RestoreApplyArtifactCheck
1005///
1006
1007#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1008pub struct RestoreApplyArtifactCheck {
1009    pub source_canister: String,
1010    pub target_canister: String,
1011    pub snapshot_id: String,
1012    pub artifact_path: String,
1013    pub resolved_path: String,
1014    pub exists: bool,
1015    pub checksum_algorithm: String,
1016    pub checksum_expected: Option<String>,
1017    pub checksum_actual: Option<String>,
1018    pub checksum_verified: bool,
1019}
1020
1021///
1022/// RestoreApplyDryRunPhase
1023///
1024
1025#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1026pub struct RestoreApplyDryRunPhase {
1027    pub restore_group: u16,
1028    pub operations: Vec<RestoreApplyDryRunOperation>,
1029}
1030
1031impl RestoreApplyDryRunPhase {
1032    // Build one dry-run phase from one restore plan phase.
1033    fn from_plan_phase(phase: &RestorePhase, next_sequence: &mut usize) -> Self {
1034        let mut operations = Vec::new();
1035
1036        for member in &phase.members {
1037            push_member_operation(
1038                &mut operations,
1039                next_sequence,
1040                RestoreApplyOperationKind::UploadSnapshot,
1041                member,
1042                None,
1043            );
1044            push_member_operation(
1045                &mut operations,
1046                next_sequence,
1047                RestoreApplyOperationKind::LoadSnapshot,
1048                member,
1049                None,
1050            );
1051            push_member_operation(
1052                &mut operations,
1053                next_sequence,
1054                RestoreApplyOperationKind::ReinstallCode,
1055                member,
1056                None,
1057            );
1058
1059            for check in &member.verification_checks {
1060                push_member_operation(
1061                    &mut operations,
1062                    next_sequence,
1063                    RestoreApplyOperationKind::VerifyMember,
1064                    member,
1065                    Some(check),
1066                );
1067            }
1068        }
1069
1070        Self {
1071            restore_group: phase.restore_group,
1072            operations,
1073        }
1074    }
1075}
1076
1077// Append one member-level dry-run operation using the current phase order.
1078fn push_member_operation(
1079    operations: &mut Vec<RestoreApplyDryRunOperation>,
1080    next_sequence: &mut usize,
1081    operation: RestoreApplyOperationKind,
1082    member: &RestorePlanMember,
1083    check: Option<&VerificationCheck>,
1084) {
1085    let sequence = *next_sequence;
1086    *next_sequence += 1;
1087
1088    operations.push(RestoreApplyDryRunOperation {
1089        sequence,
1090        operation,
1091        restore_group: member.restore_group,
1092        phase_order: member.phase_order,
1093        source_canister: member.source_canister.clone(),
1094        target_canister: member.target_canister.clone(),
1095        role: member.role.clone(),
1096        snapshot_id: Some(member.source_snapshot.snapshot_id.clone()),
1097        artifact_path: Some(member.source_snapshot.artifact_path.clone()),
1098        verification_kind: check.map(|check| check.kind.clone()),
1099        verification_method: check.and_then(|check| check.method.clone()),
1100    });
1101}
1102
1103///
1104/// RestoreApplyDryRunOperation
1105///
1106
1107#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1108pub struct RestoreApplyDryRunOperation {
1109    pub sequence: usize,
1110    pub operation: RestoreApplyOperationKind,
1111    pub restore_group: u16,
1112    pub phase_order: usize,
1113    pub source_canister: String,
1114    pub target_canister: String,
1115    pub role: String,
1116    pub snapshot_id: Option<String>,
1117    pub artifact_path: Option<String>,
1118    pub verification_kind: Option<String>,
1119    pub verification_method: Option<String>,
1120}
1121
1122///
1123/// RestoreApplyOperationKind
1124///
1125
1126#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1127#[serde(rename_all = "kebab-case")]
1128pub enum RestoreApplyOperationKind {
1129    UploadSnapshot,
1130    LoadSnapshot,
1131    ReinstallCode,
1132    VerifyMember,
1133}
1134
1135///
1136/// RestoreApplyDryRunError
1137///
1138
1139#[derive(Debug, ThisError)]
1140pub enum RestoreApplyDryRunError {
1141    #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
1142    StatusPlanMismatch {
1143        field: &'static str,
1144        plan: String,
1145        status: String,
1146    },
1147
1148    #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
1149    StatusPlanCountMismatch {
1150        field: &'static str,
1151        plan: usize,
1152        status: usize,
1153    },
1154
1155    #[error("restore artifact path for {source_canister} escapes backup root: {artifact_path}")]
1156    ArtifactPathEscapesBackup {
1157        source_canister: String,
1158        artifact_path: String,
1159    },
1160
1161    #[error(
1162        "restore artifact for {source_canister} is missing: {artifact_path} at {resolved_path}"
1163    )]
1164    ArtifactMissing {
1165        source_canister: String,
1166        artifact_path: String,
1167        resolved_path: String,
1168    },
1169
1170    #[error("restore artifact checksum failed for {source_canister} at {artifact_path}: {source}")]
1171    ArtifactChecksum {
1172        source_canister: String,
1173        artifact_path: String,
1174        #[source]
1175        source: ArtifactChecksumError,
1176    },
1177}
1178
1179///
1180/// RestoreIdentitySummary
1181///
1182
1183#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1184pub struct RestoreIdentitySummary {
1185    pub mapping_supplied: bool,
1186    pub all_sources_mapped: bool,
1187    pub fixed_members: usize,
1188    pub relocatable_members: usize,
1189    pub in_place_members: usize,
1190    pub mapped_members: usize,
1191    pub remapped_members: usize,
1192}
1193
1194///
1195/// RestoreSnapshotSummary
1196///
1197
1198#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1199#[expect(
1200    clippy::struct_excessive_bools,
1201    reason = "restore summaries intentionally expose machine-readable readiness flags"
1202)]
1203pub struct RestoreSnapshotSummary {
1204    pub all_members_have_module_hash: bool,
1205    pub all_members_have_wasm_hash: bool,
1206    pub all_members_have_code_version: bool,
1207    pub all_members_have_checksum: bool,
1208    pub members_with_module_hash: usize,
1209    pub members_with_wasm_hash: usize,
1210    pub members_with_code_version: usize,
1211    pub members_with_checksum: usize,
1212}
1213
1214///
1215/// RestoreVerificationSummary
1216///
1217
1218#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1219pub struct RestoreVerificationSummary {
1220    pub verification_required: bool,
1221    pub all_members_have_checks: bool,
1222    pub fleet_checks: usize,
1223    pub member_check_groups: usize,
1224    pub member_checks: usize,
1225    pub members_with_checks: usize,
1226    pub total_checks: usize,
1227}
1228
1229///
1230/// RestoreReadinessSummary
1231///
1232
1233#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1234pub struct RestoreReadinessSummary {
1235    pub ready: bool,
1236    pub reasons: Vec<String>,
1237}
1238
1239///
1240/// RestoreOperationSummary
1241///
1242
1243#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1244pub struct RestoreOperationSummary {
1245    pub planned_snapshot_loads: usize,
1246    pub planned_code_reinstalls: usize,
1247    pub planned_verification_checks: usize,
1248    pub planned_phases: usize,
1249}
1250
1251///
1252/// RestoreOrderingSummary
1253///
1254
1255#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1256pub struct RestoreOrderingSummary {
1257    pub phase_count: usize,
1258    pub dependency_free_members: usize,
1259    pub in_group_parent_edges: usize,
1260    pub cross_group_parent_edges: usize,
1261}
1262
1263///
1264/// RestorePhase
1265///
1266
1267#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1268pub struct RestorePhase {
1269    pub restore_group: u16,
1270    pub members: Vec<RestorePlanMember>,
1271}
1272
1273///
1274/// RestorePlanMember
1275///
1276
1277#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1278pub struct RestorePlanMember {
1279    pub source_canister: String,
1280    pub target_canister: String,
1281    pub role: String,
1282    pub parent_source_canister: Option<String>,
1283    pub parent_target_canister: Option<String>,
1284    pub ordering_dependency: Option<RestoreOrderingDependency>,
1285    pub phase_order: usize,
1286    pub restore_group: u16,
1287    pub identity_mode: IdentityMode,
1288    pub verification_class: String,
1289    pub verification_checks: Vec<VerificationCheck>,
1290    pub source_snapshot: SourceSnapshot,
1291}
1292
1293///
1294/// RestoreOrderingDependency
1295///
1296
1297#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1298pub struct RestoreOrderingDependency {
1299    pub source_canister: String,
1300    pub target_canister: String,
1301    pub relationship: RestoreOrderingRelationship,
1302}
1303
1304///
1305/// RestoreOrderingRelationship
1306///
1307
1308#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1309#[serde(rename_all = "kebab-case")]
1310pub enum RestoreOrderingRelationship {
1311    ParentInSameGroup,
1312    ParentInEarlierGroup,
1313}
1314
1315///
1316/// RestorePlanner
1317///
1318
1319pub struct RestorePlanner;
1320
1321impl RestorePlanner {
1322    /// Build a no-mutation restore plan from the manifest and optional target mapping.
1323    pub fn plan(
1324        manifest: &FleetBackupManifest,
1325        mapping: Option<&RestoreMapping>,
1326    ) -> Result<RestorePlan, RestorePlanError> {
1327        manifest.validate()?;
1328        if let Some(mapping) = mapping {
1329            validate_mapping(mapping)?;
1330            validate_mapping_sources(manifest, mapping)?;
1331        }
1332
1333        let members = resolve_members(manifest, mapping)?;
1334        let identity_summary = restore_identity_summary(&members, mapping.is_some());
1335        let snapshot_summary = restore_snapshot_summary(&members);
1336        let verification_summary = restore_verification_summary(manifest, &members);
1337        let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
1338        validate_restore_group_dependencies(&members)?;
1339        let phases = group_and_order_members(members)?;
1340        let ordering_summary = restore_ordering_summary(&phases);
1341        let operation_summary =
1342            restore_operation_summary(manifest.fleet.members.len(), &verification_summary, &phases);
1343
1344        Ok(RestorePlan {
1345            backup_id: manifest.backup_id.clone(),
1346            source_environment: manifest.source.environment.clone(),
1347            source_root_canister: manifest.source.root_canister.clone(),
1348            topology_hash: manifest.fleet.topology_hash.clone(),
1349            member_count: manifest.fleet.members.len(),
1350            identity_summary,
1351            snapshot_summary,
1352            verification_summary,
1353            readiness_summary,
1354            operation_summary,
1355            ordering_summary,
1356            phases,
1357        })
1358    }
1359}
1360
1361///
1362/// RestorePlanError
1363///
1364
1365#[derive(Debug, ThisError)]
1366pub enum RestorePlanError {
1367    #[error(transparent)]
1368    InvalidManifest(#[from] ManifestValidationError),
1369
1370    #[error("field {field} must be a valid principal: {value}")]
1371    InvalidPrincipal { field: &'static str, value: String },
1372
1373    #[error("mapping contains duplicate source canister {0}")]
1374    DuplicateMappingSource(String),
1375
1376    #[error("mapping contains duplicate target canister {0}")]
1377    DuplicateMappingTarget(String),
1378
1379    #[error("mapping references unknown source canister {0}")]
1380    UnknownMappingSource(String),
1381
1382    #[error("mapping is missing source canister {0}")]
1383    MissingMappingSource(String),
1384
1385    #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
1386    FixedIdentityRemap {
1387        source_canister: String,
1388        target_canister: String,
1389    },
1390
1391    #[error("restore plan contains duplicate target canister {0}")]
1392    DuplicatePlanTarget(String),
1393
1394    #[error("restore group {0} contains a parent cycle or unresolved dependency")]
1395    RestoreOrderCycle(u16),
1396
1397    #[error(
1398        "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
1399    )]
1400    ParentRestoreGroupAfterChild {
1401        child_source_canister: String,
1402        parent_source_canister: String,
1403        child_restore_group: u16,
1404        parent_restore_group: u16,
1405    },
1406}
1407
1408// Validate a user-supplied restore mapping before applying it to the manifest.
1409fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
1410    let mut sources = BTreeSet::new();
1411    let mut targets = BTreeSet::new();
1412
1413    for entry in &mapping.members {
1414        validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
1415        validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
1416
1417        if !sources.insert(entry.source_canister.clone()) {
1418            return Err(RestorePlanError::DuplicateMappingSource(
1419                entry.source_canister.clone(),
1420            ));
1421        }
1422
1423        if !targets.insert(entry.target_canister.clone()) {
1424            return Err(RestorePlanError::DuplicateMappingTarget(
1425                entry.target_canister.clone(),
1426            ));
1427        }
1428    }
1429
1430    Ok(())
1431}
1432
1433// Ensure mappings only reference members declared in the manifest.
1434fn validate_mapping_sources(
1435    manifest: &FleetBackupManifest,
1436    mapping: &RestoreMapping,
1437) -> Result<(), RestorePlanError> {
1438    let sources = manifest
1439        .fleet
1440        .members
1441        .iter()
1442        .map(|member| member.canister_id.as_str())
1443        .collect::<BTreeSet<_>>();
1444
1445    for entry in &mapping.members {
1446        if !sources.contains(entry.source_canister.as_str()) {
1447            return Err(RestorePlanError::UnknownMappingSource(
1448                entry.source_canister.clone(),
1449            ));
1450        }
1451    }
1452
1453    Ok(())
1454}
1455
1456// Resolve source manifest members into target restore members.
1457fn resolve_members(
1458    manifest: &FleetBackupManifest,
1459    mapping: Option<&RestoreMapping>,
1460) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
1461    let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
1462    let mut targets = BTreeSet::new();
1463    let mut source_to_target = BTreeMap::new();
1464
1465    for member in &manifest.fleet.members {
1466        let target = resolve_target(member, mapping)?;
1467        if !targets.insert(target.clone()) {
1468            return Err(RestorePlanError::DuplicatePlanTarget(target));
1469        }
1470
1471        source_to_target.insert(member.canister_id.clone(), target.clone());
1472        plan_members.push(RestorePlanMember {
1473            source_canister: member.canister_id.clone(),
1474            target_canister: target,
1475            role: member.role.clone(),
1476            parent_source_canister: member.parent_canister_id.clone(),
1477            parent_target_canister: None,
1478            ordering_dependency: None,
1479            phase_order: 0,
1480            restore_group: member.restore_group,
1481            identity_mode: member.identity_mode.clone(),
1482            verification_class: member.verification_class.clone(),
1483            verification_checks: member.verification_checks.clone(),
1484            source_snapshot: member.source_snapshot.clone(),
1485        });
1486    }
1487
1488    for member in &mut plan_members {
1489        member.parent_target_canister = member
1490            .parent_source_canister
1491            .as_ref()
1492            .and_then(|parent| source_to_target.get(parent))
1493            .cloned();
1494    }
1495
1496    Ok(plan_members)
1497}
1498
1499// Resolve one member's target canister, enforcing identity continuity.
1500fn resolve_target(
1501    member: &FleetMember,
1502    mapping: Option<&RestoreMapping>,
1503) -> Result<String, RestorePlanError> {
1504    let target = match mapping {
1505        Some(mapping) => mapping
1506            .target_for(&member.canister_id)
1507            .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
1508            .to_string(),
1509        None => member.canister_id.clone(),
1510    };
1511
1512    if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
1513        return Err(RestorePlanError::FixedIdentityRemap {
1514            source_canister: member.canister_id.clone(),
1515            target_canister: target,
1516        });
1517    }
1518
1519    Ok(target)
1520}
1521
1522// Summarize identity and mapping decisions before grouping restore phases.
1523fn restore_identity_summary(
1524    members: &[RestorePlanMember],
1525    mapping_supplied: bool,
1526) -> RestoreIdentitySummary {
1527    let mut summary = RestoreIdentitySummary {
1528        mapping_supplied,
1529        all_sources_mapped: false,
1530        fixed_members: 0,
1531        relocatable_members: 0,
1532        in_place_members: 0,
1533        mapped_members: 0,
1534        remapped_members: 0,
1535    };
1536
1537    for member in members {
1538        match member.identity_mode {
1539            IdentityMode::Fixed => summary.fixed_members += 1,
1540            IdentityMode::Relocatable => summary.relocatable_members += 1,
1541        }
1542
1543        if member.source_canister == member.target_canister {
1544            summary.in_place_members += 1;
1545        } else {
1546            summary.remapped_members += 1;
1547        }
1548        if mapping_supplied {
1549            summary.mapped_members += 1;
1550        }
1551    }
1552
1553    summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
1554
1555    summary
1556}
1557
1558// Summarize snapshot provenance completeness before grouping restore phases.
1559fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
1560    let members_with_module_hash = members
1561        .iter()
1562        .filter(|member| member.source_snapshot.module_hash.is_some())
1563        .count();
1564    let members_with_wasm_hash = members
1565        .iter()
1566        .filter(|member| member.source_snapshot.wasm_hash.is_some())
1567        .count();
1568    let members_with_code_version = members
1569        .iter()
1570        .filter(|member| member.source_snapshot.code_version.is_some())
1571        .count();
1572    let members_with_checksum = members
1573        .iter()
1574        .filter(|member| member.source_snapshot.checksum.is_some())
1575        .count();
1576
1577    RestoreSnapshotSummary {
1578        all_members_have_module_hash: members_with_module_hash == members.len(),
1579        all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
1580        all_members_have_code_version: members_with_code_version == members.len(),
1581        all_members_have_checksum: members_with_checksum == members.len(),
1582        members_with_module_hash,
1583        members_with_wasm_hash,
1584        members_with_code_version,
1585        members_with_checksum,
1586    }
1587}
1588
1589// Summarize whether restore planning has the metadata required for automation.
1590fn restore_readiness_summary(
1591    snapshot: &RestoreSnapshotSummary,
1592    verification: &RestoreVerificationSummary,
1593) -> RestoreReadinessSummary {
1594    let mut reasons = Vec::new();
1595
1596    if !snapshot.all_members_have_module_hash {
1597        reasons.push("missing-module-hash".to_string());
1598    }
1599    if !snapshot.all_members_have_wasm_hash {
1600        reasons.push("missing-wasm-hash".to_string());
1601    }
1602    if !snapshot.all_members_have_code_version {
1603        reasons.push("missing-code-version".to_string());
1604    }
1605    if !snapshot.all_members_have_checksum {
1606        reasons.push("missing-snapshot-checksum".to_string());
1607    }
1608    if !verification.all_members_have_checks {
1609        reasons.push("missing-verification-checks".to_string());
1610    }
1611
1612    RestoreReadinessSummary {
1613        ready: reasons.is_empty(),
1614        reasons,
1615    }
1616}
1617
1618// Summarize restore verification work declared by the manifest and members.
1619fn restore_verification_summary(
1620    manifest: &FleetBackupManifest,
1621    members: &[RestorePlanMember],
1622) -> RestoreVerificationSummary {
1623    let fleet_checks = manifest.verification.fleet_checks.len();
1624    let member_check_groups = manifest.verification.member_checks.len();
1625    let role_check_counts = manifest
1626        .verification
1627        .member_checks
1628        .iter()
1629        .map(|group| (group.role.as_str(), group.checks.len()))
1630        .collect::<BTreeMap<_, _>>();
1631    let inline_member_checks = members
1632        .iter()
1633        .map(|member| member.verification_checks.len())
1634        .sum::<usize>();
1635    let role_member_checks = members
1636        .iter()
1637        .map(|member| {
1638            role_check_counts
1639                .get(member.role.as_str())
1640                .copied()
1641                .unwrap_or(0)
1642        })
1643        .sum::<usize>();
1644    let member_checks = inline_member_checks + role_member_checks;
1645    let members_with_checks = members
1646        .iter()
1647        .filter(|member| {
1648            !member.verification_checks.is_empty()
1649                || role_check_counts.contains_key(member.role.as_str())
1650        })
1651        .count();
1652
1653    RestoreVerificationSummary {
1654        verification_required: true,
1655        all_members_have_checks: members_with_checks == members.len(),
1656        fleet_checks,
1657        member_check_groups,
1658        member_checks,
1659        members_with_checks,
1660        total_checks: fleet_checks + member_checks,
1661    }
1662}
1663
1664// Summarize the concrete restore operations implied by a no-mutation plan.
1665const fn restore_operation_summary(
1666    member_count: usize,
1667    verification_summary: &RestoreVerificationSummary,
1668    phases: &[RestorePhase],
1669) -> RestoreOperationSummary {
1670    RestoreOperationSummary {
1671        planned_snapshot_loads: member_count,
1672        planned_code_reinstalls: member_count,
1673        planned_verification_checks: verification_summary.total_checks,
1674        planned_phases: phases.len(),
1675    }
1676}
1677
1678// Reject group assignments that would restore a child before its parent.
1679fn validate_restore_group_dependencies(
1680    members: &[RestorePlanMember],
1681) -> Result<(), RestorePlanError> {
1682    let groups_by_source = members
1683        .iter()
1684        .map(|member| (member.source_canister.as_str(), member.restore_group))
1685        .collect::<BTreeMap<_, _>>();
1686
1687    for member in members {
1688        let Some(parent) = &member.parent_source_canister else {
1689            continue;
1690        };
1691        let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
1692            continue;
1693        };
1694
1695        if *parent_group > member.restore_group {
1696            return Err(RestorePlanError::ParentRestoreGroupAfterChild {
1697                child_source_canister: member.source_canister.clone(),
1698                parent_source_canister: parent.clone(),
1699                child_restore_group: member.restore_group,
1700                parent_restore_group: *parent_group,
1701            });
1702        }
1703    }
1704
1705    Ok(())
1706}
1707
1708// Group members and apply parent-before-child ordering inside each group.
1709fn group_and_order_members(
1710    members: Vec<RestorePlanMember>,
1711) -> Result<Vec<RestorePhase>, RestorePlanError> {
1712    let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
1713    for member in members {
1714        groups.entry(member.restore_group).or_default().push(member);
1715    }
1716
1717    groups
1718        .into_iter()
1719        .map(|(restore_group, members)| {
1720            let members = order_group(restore_group, members)?;
1721            Ok(RestorePhase {
1722                restore_group,
1723                members,
1724            })
1725        })
1726        .collect()
1727}
1728
1729// Topologically order one group using manifest parent relationships.
1730fn order_group(
1731    restore_group: u16,
1732    members: Vec<RestorePlanMember>,
1733) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
1734    let mut remaining = members;
1735    let group_sources = remaining
1736        .iter()
1737        .map(|member| member.source_canister.clone())
1738        .collect::<BTreeSet<_>>();
1739    let mut emitted = BTreeSet::new();
1740    let mut ordered = Vec::with_capacity(remaining.len());
1741
1742    while !remaining.is_empty() {
1743        let Some(index) = remaining
1744            .iter()
1745            .position(|member| parent_satisfied(member, &group_sources, &emitted))
1746        else {
1747            return Err(RestorePlanError::RestoreOrderCycle(restore_group));
1748        };
1749
1750        let mut member = remaining.remove(index);
1751        member.phase_order = ordered.len();
1752        member.ordering_dependency = ordering_dependency(&member, &group_sources);
1753        emitted.insert(member.source_canister.clone());
1754        ordered.push(member);
1755    }
1756
1757    Ok(ordered)
1758}
1759
1760// Describe the topology dependency that controlled a member's restore ordering.
1761fn ordering_dependency(
1762    member: &RestorePlanMember,
1763    group_sources: &BTreeSet<String>,
1764) -> Option<RestoreOrderingDependency> {
1765    let parent_source = member.parent_source_canister.as_ref()?;
1766    let parent_target = member.parent_target_canister.as_ref()?;
1767    let relationship = if group_sources.contains(parent_source) {
1768        RestoreOrderingRelationship::ParentInSameGroup
1769    } else {
1770        RestoreOrderingRelationship::ParentInEarlierGroup
1771    };
1772
1773    Some(RestoreOrderingDependency {
1774        source_canister: parent_source.clone(),
1775        target_canister: parent_target.clone(),
1776        relationship,
1777    })
1778}
1779
1780// Summarize the dependency ordering metadata exposed in the restore plan.
1781fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
1782    let mut summary = RestoreOrderingSummary {
1783        phase_count: phases.len(),
1784        dependency_free_members: 0,
1785        in_group_parent_edges: 0,
1786        cross_group_parent_edges: 0,
1787    };
1788
1789    for member in phases.iter().flat_map(|phase| phase.members.iter()) {
1790        match &member.ordering_dependency {
1791            Some(dependency)
1792                if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
1793            {
1794                summary.in_group_parent_edges += 1;
1795            }
1796            Some(dependency)
1797                if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
1798            {
1799                summary.cross_group_parent_edges += 1;
1800            }
1801            Some(_) => {}
1802            None => summary.dependency_free_members += 1,
1803        }
1804    }
1805
1806    summary
1807}
1808
1809// Determine whether a member's in-group parent has already been emitted.
1810fn parent_satisfied(
1811    member: &RestorePlanMember,
1812    group_sources: &BTreeSet<String>,
1813    emitted: &BTreeSet<String>,
1814) -> bool {
1815    match &member.parent_source_canister {
1816        Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
1817        _ => true,
1818    }
1819}
1820
1821// Validate textual principal fields used in mappings.
1822fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
1823    Principal::from_str(value)
1824        .map(|_| ())
1825        .map_err(|_| RestorePlanError::InvalidPrincipal {
1826            field,
1827            value: value.to_string(),
1828        })
1829}
1830
1831#[cfg(test)]
1832mod tests {
1833    use super::*;
1834    use crate::manifest::{
1835        BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetSection,
1836        MemberVerificationChecks, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
1837        VerificationPlan,
1838    };
1839    use std::{
1840        env, fs,
1841        path::{Path, PathBuf},
1842        time::{SystemTime, UNIX_EPOCH},
1843    };
1844
1845    const ROOT: &str = "aaaaa-aa";
1846    const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
1847    const CHILD_TWO: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";
1848    const TARGET: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
1849    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1850
1851    // Build one valid manifest with a parent and child in the same restore group.
1852    fn valid_manifest(identity_mode: IdentityMode) -> FleetBackupManifest {
1853        FleetBackupManifest {
1854            manifest_version: 1,
1855            backup_id: "fbk_test_001".to_string(),
1856            created_at: "2026-04-10T12:00:00Z".to_string(),
1857            tool: ToolMetadata {
1858                name: "canic".to_string(),
1859                version: "v1".to_string(),
1860            },
1861            source: SourceMetadata {
1862                environment: "local".to_string(),
1863                root_canister: ROOT.to_string(),
1864            },
1865            consistency: ConsistencySection {
1866                mode: ConsistencyMode::CrashConsistent,
1867                backup_units: vec![BackupUnit {
1868                    unit_id: "whole-fleet".to_string(),
1869                    kind: BackupUnitKind::WholeFleet,
1870                    roles: vec!["root".to_string(), "app".to_string()],
1871                    consistency_reason: None,
1872                    dependency_closure: Vec::new(),
1873                    topology_validation: "subtree-closed".to_string(),
1874                    quiescence_strategy: None,
1875                }],
1876            },
1877            fleet: FleetSection {
1878                topology_hash_algorithm: "sha256".to_string(),
1879                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1880                discovery_topology_hash: HASH.to_string(),
1881                pre_snapshot_topology_hash: HASH.to_string(),
1882                topology_hash: HASH.to_string(),
1883                members: vec![
1884                    fleet_member("app", CHILD, Some(ROOT), identity_mode, 1),
1885                    fleet_member("root", ROOT, None, IdentityMode::Fixed, 1),
1886                ],
1887            },
1888            verification: VerificationPlan {
1889                fleet_checks: Vec::new(),
1890                member_checks: Vec::new(),
1891            },
1892        }
1893    }
1894
1895    // Build one manifest member for restore planning tests.
1896    fn fleet_member(
1897        role: &str,
1898        canister_id: &str,
1899        parent_canister_id: Option<&str>,
1900        identity_mode: IdentityMode,
1901        restore_group: u16,
1902    ) -> FleetMember {
1903        FleetMember {
1904            role: role.to_string(),
1905            canister_id: canister_id.to_string(),
1906            parent_canister_id: parent_canister_id.map(str::to_string),
1907            subnet_canister_id: None,
1908            controller_hint: Some(ROOT.to_string()),
1909            identity_mode,
1910            restore_group,
1911            verification_class: "basic".to_string(),
1912            verification_checks: vec![VerificationCheck {
1913                kind: "call".to_string(),
1914                method: Some("canic_ready".to_string()),
1915                roles: Vec::new(),
1916            }],
1917            source_snapshot: SourceSnapshot {
1918                snapshot_id: format!("snap-{role}"),
1919                module_hash: Some(HASH.to_string()),
1920                wasm_hash: Some(HASH.to_string()),
1921                code_version: Some("v0.30.0".to_string()),
1922                artifact_path: format!("artifacts/{role}"),
1923                checksum_algorithm: "sha256".to_string(),
1924                checksum: Some(HASH.to_string()),
1925            },
1926        }
1927    }
1928
1929    // Ensure in-place restore planning sorts parent before child.
1930    #[test]
1931    fn in_place_plan_orders_parent_before_child() {
1932        let manifest = valid_manifest(IdentityMode::Relocatable);
1933
1934        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1935        let ordered = plan.ordered_members();
1936
1937        assert_eq!(plan.backup_id, "fbk_test_001");
1938        assert_eq!(plan.source_environment, "local");
1939        assert_eq!(plan.source_root_canister, ROOT);
1940        assert_eq!(plan.topology_hash, HASH);
1941        assert_eq!(plan.member_count, 2);
1942        assert_eq!(plan.identity_summary.fixed_members, 1);
1943        assert_eq!(plan.identity_summary.relocatable_members, 1);
1944        assert_eq!(plan.identity_summary.in_place_members, 2);
1945        assert_eq!(plan.identity_summary.mapped_members, 0);
1946        assert_eq!(plan.identity_summary.remapped_members, 0);
1947        assert!(plan.verification_summary.verification_required);
1948        assert!(plan.verification_summary.all_members_have_checks);
1949        assert!(plan.readiness_summary.ready);
1950        assert!(plan.readiness_summary.reasons.is_empty());
1951        assert_eq!(plan.verification_summary.fleet_checks, 0);
1952        assert_eq!(plan.verification_summary.member_check_groups, 0);
1953        assert_eq!(plan.verification_summary.member_checks, 2);
1954        assert_eq!(plan.verification_summary.members_with_checks, 2);
1955        assert_eq!(plan.verification_summary.total_checks, 2);
1956        assert_eq!(plan.ordering_summary.phase_count, 1);
1957        assert_eq!(plan.ordering_summary.dependency_free_members, 1);
1958        assert_eq!(plan.ordering_summary.in_group_parent_edges, 1);
1959        assert_eq!(plan.ordering_summary.cross_group_parent_edges, 0);
1960        assert_eq!(ordered[0].phase_order, 0);
1961        assert_eq!(ordered[1].phase_order, 1);
1962        assert_eq!(ordered[0].source_canister, ROOT);
1963        assert_eq!(ordered[1].source_canister, CHILD);
1964        assert_eq!(
1965            ordered[1].ordering_dependency,
1966            Some(RestoreOrderingDependency {
1967                source_canister: ROOT.to_string(),
1968                target_canister: ROOT.to_string(),
1969                relationship: RestoreOrderingRelationship::ParentInSameGroup,
1970            })
1971        );
1972    }
1973
1974    // Ensure cross-group parent dependencies are exposed when the parent phase is earlier.
1975    #[test]
1976    fn plan_reports_parent_dependency_from_earlier_group() {
1977        let mut manifest = valid_manifest(IdentityMode::Relocatable);
1978        manifest.fleet.members[0].restore_group = 2;
1979        manifest.fleet.members[1].restore_group = 1;
1980
1981        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1982        let ordered = plan.ordered_members();
1983
1984        assert_eq!(plan.phases.len(), 2);
1985        assert_eq!(plan.ordering_summary.phase_count, 2);
1986        assert_eq!(plan.ordering_summary.dependency_free_members, 1);
1987        assert_eq!(plan.ordering_summary.in_group_parent_edges, 0);
1988        assert_eq!(plan.ordering_summary.cross_group_parent_edges, 1);
1989        assert_eq!(ordered[0].source_canister, ROOT);
1990        assert_eq!(ordered[1].source_canister, CHILD);
1991        assert_eq!(
1992            ordered[1].ordering_dependency,
1993            Some(RestoreOrderingDependency {
1994                source_canister: ROOT.to_string(),
1995                target_canister: ROOT.to_string(),
1996                relationship: RestoreOrderingRelationship::ParentInEarlierGroup,
1997            })
1998        );
1999    }
2000
2001    // Ensure restore planning fails when groups would restore a child before its parent.
2002    #[test]
2003    fn plan_rejects_parent_in_later_restore_group() {
2004        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2005        manifest.fleet.members[0].restore_group = 1;
2006        manifest.fleet.members[1].restore_group = 2;
2007
2008        let err = RestorePlanner::plan(&manifest, None)
2009            .expect_err("parent-after-child group ordering should fail");
2010
2011        assert!(matches!(
2012            err,
2013            RestorePlanError::ParentRestoreGroupAfterChild { .. }
2014        ));
2015    }
2016
2017    // Ensure fixed identities cannot be remapped.
2018    #[test]
2019    fn fixed_identity_member_cannot_be_remapped() {
2020        let manifest = valid_manifest(IdentityMode::Fixed);
2021        let mapping = RestoreMapping {
2022            members: vec![
2023                RestoreMappingEntry {
2024                    source_canister: ROOT.to_string(),
2025                    target_canister: ROOT.to_string(),
2026                },
2027                RestoreMappingEntry {
2028                    source_canister: CHILD.to_string(),
2029                    target_canister: TARGET.to_string(),
2030                },
2031            ],
2032        };
2033
2034        let err = RestorePlanner::plan(&manifest, Some(&mapping))
2035            .expect_err("fixed member remap should fail");
2036
2037        assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
2038    }
2039
2040    // Ensure relocatable identities may be mapped when all members are covered.
2041    #[test]
2042    fn relocatable_member_can_be_mapped() {
2043        let manifest = valid_manifest(IdentityMode::Relocatable);
2044        let mapping = RestoreMapping {
2045            members: vec![
2046                RestoreMappingEntry {
2047                    source_canister: ROOT.to_string(),
2048                    target_canister: ROOT.to_string(),
2049                },
2050                RestoreMappingEntry {
2051                    source_canister: CHILD.to_string(),
2052                    target_canister: TARGET.to_string(),
2053                },
2054            ],
2055        };
2056
2057        let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
2058        let child = plan
2059            .ordered_members()
2060            .into_iter()
2061            .find(|member| member.source_canister == CHILD)
2062            .expect("child member should be planned");
2063
2064        assert_eq!(plan.identity_summary.fixed_members, 1);
2065        assert_eq!(plan.identity_summary.relocatable_members, 1);
2066        assert_eq!(plan.identity_summary.in_place_members, 1);
2067        assert_eq!(plan.identity_summary.mapped_members, 2);
2068        assert_eq!(plan.identity_summary.remapped_members, 1);
2069        assert_eq!(child.target_canister, TARGET);
2070        assert_eq!(child.parent_target_canister, Some(ROOT.to_string()));
2071    }
2072
2073    // Ensure restore plans carry enough metadata for operator preflight.
2074    #[test]
2075    fn plan_members_include_snapshot_and_verification_metadata() {
2076        let manifest = valid_manifest(IdentityMode::Relocatable);
2077
2078        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2079        let root = plan
2080            .ordered_members()
2081            .into_iter()
2082            .find(|member| member.source_canister == ROOT)
2083            .expect("root member should be planned");
2084
2085        assert_eq!(root.identity_mode, IdentityMode::Fixed);
2086        assert_eq!(root.verification_class, "basic");
2087        assert_eq!(root.verification_checks[0].kind, "call");
2088        assert_eq!(root.source_snapshot.snapshot_id, "snap-root");
2089        assert_eq!(root.source_snapshot.artifact_path, "artifacts/root");
2090    }
2091
2092    // Ensure restore plans make mapping mode explicit.
2093    #[test]
2094    fn plan_includes_mapping_summary() {
2095        let manifest = valid_manifest(IdentityMode::Relocatable);
2096        let in_place = RestorePlanner::plan(&manifest, None).expect("plan should build");
2097
2098        assert!(!in_place.identity_summary.mapping_supplied);
2099        assert!(!in_place.identity_summary.all_sources_mapped);
2100        assert_eq!(in_place.identity_summary.mapped_members, 0);
2101
2102        let mapping = RestoreMapping {
2103            members: vec![
2104                RestoreMappingEntry {
2105                    source_canister: ROOT.to_string(),
2106                    target_canister: ROOT.to_string(),
2107                },
2108                RestoreMappingEntry {
2109                    source_canister: CHILD.to_string(),
2110                    target_canister: TARGET.to_string(),
2111                },
2112            ],
2113        };
2114        let mapped = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
2115
2116        assert!(mapped.identity_summary.mapping_supplied);
2117        assert!(mapped.identity_summary.all_sources_mapped);
2118        assert_eq!(mapped.identity_summary.mapped_members, 2);
2119        assert_eq!(mapped.identity_summary.remapped_members, 1);
2120    }
2121
2122    // Ensure restore plans summarize snapshot provenance completeness.
2123    #[test]
2124    fn plan_includes_snapshot_summary() {
2125        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2126        manifest.fleet.members[1].source_snapshot.module_hash = None;
2127        manifest.fleet.members[1].source_snapshot.wasm_hash = None;
2128        manifest.fleet.members[1].source_snapshot.checksum = None;
2129
2130        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2131
2132        assert!(!plan.snapshot_summary.all_members_have_module_hash);
2133        assert!(!plan.snapshot_summary.all_members_have_wasm_hash);
2134        assert!(plan.snapshot_summary.all_members_have_code_version);
2135        assert!(!plan.snapshot_summary.all_members_have_checksum);
2136        assert_eq!(plan.snapshot_summary.members_with_module_hash, 1);
2137        assert_eq!(plan.snapshot_summary.members_with_wasm_hash, 1);
2138        assert_eq!(plan.snapshot_summary.members_with_code_version, 2);
2139        assert_eq!(plan.snapshot_summary.members_with_checksum, 1);
2140        assert!(!plan.readiness_summary.ready);
2141        assert_eq!(
2142            plan.readiness_summary.reasons,
2143            [
2144                "missing-module-hash",
2145                "missing-wasm-hash",
2146                "missing-snapshot-checksum"
2147            ]
2148        );
2149    }
2150
2151    // Ensure restore plans summarize manifest-level verification work.
2152    #[test]
2153    fn plan_includes_verification_summary() {
2154        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2155        manifest.verification.fleet_checks.push(VerificationCheck {
2156            kind: "fleet-ready".to_string(),
2157            method: None,
2158            roles: Vec::new(),
2159        });
2160        manifest
2161            .verification
2162            .member_checks
2163            .push(MemberVerificationChecks {
2164                role: "app".to_string(),
2165                checks: vec![VerificationCheck {
2166                    kind: "app-ready".to_string(),
2167                    method: Some("ready".to_string()),
2168                    roles: Vec::new(),
2169                }],
2170            });
2171
2172        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2173
2174        assert!(plan.verification_summary.verification_required);
2175        assert!(plan.verification_summary.all_members_have_checks);
2176        assert_eq!(plan.verification_summary.fleet_checks, 1);
2177        assert_eq!(plan.verification_summary.member_check_groups, 1);
2178        assert_eq!(plan.verification_summary.member_checks, 3);
2179        assert_eq!(plan.verification_summary.members_with_checks, 2);
2180        assert_eq!(plan.verification_summary.total_checks, 4);
2181    }
2182
2183    // Ensure restore plans summarize the concrete operation counts automation will schedule.
2184    #[test]
2185    fn plan_includes_operation_summary() {
2186        let manifest = valid_manifest(IdentityMode::Relocatable);
2187
2188        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2189
2190        assert_eq!(plan.operation_summary.planned_snapshot_loads, 2);
2191        assert_eq!(plan.operation_summary.planned_code_reinstalls, 2);
2192        assert_eq!(plan.operation_summary.planned_verification_checks, 2);
2193        assert_eq!(plan.operation_summary.planned_phases, 1);
2194    }
2195
2196    // Ensure initial restore status mirrors the no-mutation restore plan.
2197    #[test]
2198    fn restore_status_starts_all_members_as_planned() {
2199        let manifest = valid_manifest(IdentityMode::Relocatable);
2200
2201        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2202        let status = RestoreStatus::from_plan(&plan);
2203
2204        assert_eq!(status.status_version, 1);
2205        assert_eq!(status.backup_id.as_str(), plan.backup_id.as_str());
2206        assert_eq!(
2207            status.source_environment.as_str(),
2208            plan.source_environment.as_str()
2209        );
2210        assert_eq!(
2211            status.source_root_canister.as_str(),
2212            plan.source_root_canister.as_str()
2213        );
2214        assert_eq!(status.topology_hash.as_str(), plan.topology_hash.as_str());
2215        assert!(status.ready);
2216        assert!(status.readiness_reasons.is_empty());
2217        assert!(status.verification_required);
2218        assert_eq!(status.member_count, 2);
2219        assert_eq!(status.phase_count, 1);
2220        assert_eq!(status.planned_snapshot_loads, 2);
2221        assert_eq!(status.planned_code_reinstalls, 2);
2222        assert_eq!(status.planned_verification_checks, 2);
2223        assert_eq!(status.phases.len(), 1);
2224        assert_eq!(status.phases[0].restore_group, 1);
2225        assert_eq!(status.phases[0].members.len(), 2);
2226        assert_eq!(
2227            status.phases[0].members[0].state,
2228            RestoreMemberState::Planned
2229        );
2230        assert_eq!(status.phases[0].members[0].source_canister, ROOT);
2231        assert_eq!(status.phases[0].members[0].target_canister, ROOT);
2232        assert_eq!(status.phases[0].members[0].snapshot_id, "snap-root");
2233        assert_eq!(status.phases[0].members[0].artifact_path, "artifacts/root");
2234        assert_eq!(
2235            status.phases[0].members[1].state,
2236            RestoreMemberState::Planned
2237        );
2238        assert_eq!(status.phases[0].members[1].source_canister, CHILD);
2239    }
2240
2241    // Ensure apply dry-runs render ordered operations without mutating targets.
2242    #[test]
2243    fn apply_dry_run_renders_ordered_member_operations() {
2244        let manifest = valid_manifest(IdentityMode::Relocatable);
2245
2246        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2247        let status = RestoreStatus::from_plan(&plan);
2248        let dry_run =
2249            RestoreApplyDryRun::try_from_plan(&plan, Some(&status)).expect("dry-run should build");
2250
2251        assert_eq!(dry_run.dry_run_version, 1);
2252        assert_eq!(dry_run.backup_id.as_str(), "fbk_test_001");
2253        assert!(dry_run.ready);
2254        assert!(dry_run.status_supplied);
2255        assert_eq!(dry_run.member_count, 2);
2256        assert_eq!(dry_run.phase_count, 1);
2257        assert_eq!(dry_run.planned_snapshot_loads, 2);
2258        assert_eq!(dry_run.planned_code_reinstalls, 2);
2259        assert_eq!(dry_run.planned_verification_checks, 2);
2260        assert_eq!(dry_run.rendered_operations, 8);
2261        assert_eq!(dry_run.phases.len(), 1);
2262
2263        let operations = &dry_run.phases[0].operations;
2264        assert_eq!(operations[0].sequence, 0);
2265        assert_eq!(
2266            operations[0].operation,
2267            RestoreApplyOperationKind::UploadSnapshot
2268        );
2269        assert_eq!(operations[0].source_canister, ROOT);
2270        assert_eq!(operations[0].target_canister, ROOT);
2271        assert_eq!(operations[0].snapshot_id, Some("snap-root".to_string()));
2272        assert_eq!(
2273            operations[0].artifact_path,
2274            Some("artifacts/root".to_string())
2275        );
2276        assert_eq!(
2277            operations[1].operation,
2278            RestoreApplyOperationKind::LoadSnapshot
2279        );
2280        assert_eq!(
2281            operations[2].operation,
2282            RestoreApplyOperationKind::ReinstallCode
2283        );
2284        assert_eq!(
2285            operations[3].operation,
2286            RestoreApplyOperationKind::VerifyMember
2287        );
2288        assert_eq!(operations[3].verification_kind, Some("call".to_string()));
2289        assert_eq!(
2290            operations[3].verification_method,
2291            Some("canic_ready".to_string())
2292        );
2293        assert_eq!(operations[4].source_canister, CHILD);
2294        assert_eq!(
2295            operations[7].operation,
2296            RestoreApplyOperationKind::VerifyMember
2297        );
2298    }
2299
2300    // Ensure apply dry-run operation sequences remain unique across phases.
2301    #[test]
2302    fn apply_dry_run_sequences_operations_across_phases() {
2303        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2304        manifest.fleet.members[0].restore_group = 2;
2305
2306        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2307        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
2308
2309        assert_eq!(dry_run.phases.len(), 2);
2310        assert_eq!(dry_run.rendered_operations, 8);
2311        assert_eq!(dry_run.phases[0].operations[0].sequence, 0);
2312        assert_eq!(dry_run.phases[0].operations[3].sequence, 3);
2313        assert_eq!(dry_run.phases[1].operations[0].sequence, 4);
2314        assert_eq!(dry_run.phases[1].operations[3].sequence, 7);
2315    }
2316
2317    // Ensure apply dry-runs can prove referenced artifacts exist and match checksums.
2318    #[test]
2319    fn apply_dry_run_validates_artifacts_under_backup_root() {
2320        let root = temp_dir("canic-restore-apply-artifacts-ok");
2321        fs::create_dir_all(&root).expect("create temp root");
2322        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2323        set_member_artifact(
2324            &mut manifest,
2325            CHILD,
2326            &root,
2327            "artifacts/child",
2328            b"child-snapshot",
2329        );
2330        set_member_artifact(
2331            &mut manifest,
2332            ROOT,
2333            &root,
2334            "artifacts/root",
2335            b"root-snapshot",
2336        );
2337
2338        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2339        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
2340            .expect("dry-run should validate artifacts");
2341
2342        let validation = dry_run
2343            .artifact_validation
2344            .expect("artifact validation should be present");
2345        assert_eq!(validation.checked_members, 2);
2346        assert!(validation.artifacts_present);
2347        assert!(validation.checksums_verified);
2348        assert_eq!(validation.members_with_expected_checksums, 2);
2349        assert_eq!(validation.checks[0].source_canister, ROOT);
2350        assert!(validation.checks[0].checksum_verified);
2351
2352        fs::remove_dir_all(root).expect("remove temp root");
2353    }
2354
2355    // Ensure an artifact-validated apply dry-run produces a ready initial journal.
2356    #[test]
2357    fn apply_journal_marks_validated_operations_ready() {
2358        let root = temp_dir("canic-restore-apply-journal-ready");
2359        fs::create_dir_all(&root).expect("create temp root");
2360        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2361        set_member_artifact(
2362            &mut manifest,
2363            CHILD,
2364            &root,
2365            "artifacts/child",
2366            b"child-snapshot",
2367        );
2368        set_member_artifact(
2369            &mut manifest,
2370            ROOT,
2371            &root,
2372            "artifacts/root",
2373            b"root-snapshot",
2374        );
2375
2376        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2377        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
2378            .expect("dry-run should validate artifacts");
2379        let journal = RestoreApplyJournal::from_dry_run(&dry_run);
2380
2381        fs::remove_dir_all(root).expect("remove temp root");
2382        assert_eq!(journal.journal_version, 1);
2383        assert_eq!(journal.backup_id.as_str(), "fbk_test_001");
2384        assert!(journal.ready);
2385        assert!(journal.blocked_reasons.is_empty());
2386        assert_eq!(journal.operation_count, 8);
2387        assert_eq!(journal.ready_operations, 8);
2388        assert_eq!(journal.blocked_operations, 0);
2389        assert_eq!(journal.operations[0].sequence, 0);
2390        assert_eq!(
2391            journal.operations[0].state,
2392            RestoreApplyOperationState::Ready
2393        );
2394        assert!(journal.operations[0].blocking_reasons.is_empty());
2395    }
2396
2397    // Ensure apply journals block when artifact validation was not supplied.
2398    #[test]
2399    fn apply_journal_blocks_without_artifact_validation() {
2400        let manifest = valid_manifest(IdentityMode::Relocatable);
2401
2402        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2403        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
2404        let journal = RestoreApplyJournal::from_dry_run(&dry_run);
2405
2406        assert!(!journal.ready);
2407        assert_eq!(journal.ready_operations, 0);
2408        assert_eq!(journal.blocked_operations, 8);
2409        assert!(
2410            journal
2411                .blocked_reasons
2412                .contains(&"missing-artifact-validation".to_string())
2413        );
2414        assert!(
2415            journal.operations[0]
2416                .blocking_reasons
2417                .contains(&"missing-artifact-validation".to_string())
2418        );
2419    }
2420
2421    // Ensure apply journal status exposes compact readiness and next-operation state.
2422    #[test]
2423    fn apply_journal_status_reports_next_ready_operation() {
2424        let root = temp_dir("canic-restore-apply-journal-status");
2425        fs::create_dir_all(&root).expect("create temp root");
2426        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2427        set_member_artifact(
2428            &mut manifest,
2429            CHILD,
2430            &root,
2431            "artifacts/child",
2432            b"child-snapshot",
2433        );
2434        set_member_artifact(
2435            &mut manifest,
2436            ROOT,
2437            &root,
2438            "artifacts/root",
2439            b"root-snapshot",
2440        );
2441
2442        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2443        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
2444            .expect("dry-run should validate artifacts");
2445        let journal = RestoreApplyJournal::from_dry_run(&dry_run);
2446        let status = journal.status();
2447
2448        fs::remove_dir_all(root).expect("remove temp root");
2449        assert_eq!(status.status_version, 1);
2450        assert_eq!(status.backup_id.as_str(), "fbk_test_001");
2451        assert!(status.ready);
2452        assert!(!status.complete);
2453        assert_eq!(status.operation_count, 8);
2454        assert_eq!(status.ready_operations, 8);
2455        assert_eq!(status.next_ready_sequence, Some(0));
2456        assert_eq!(
2457            status.next_ready_operation,
2458            Some(RestoreApplyOperationKind::UploadSnapshot)
2459        );
2460    }
2461
2462    // Ensure next-operation output exposes the full next ready journal row.
2463    #[test]
2464    fn apply_journal_next_operation_reports_full_ready_row() {
2465        let root = temp_dir("canic-restore-apply-journal-next");
2466        fs::create_dir_all(&root).expect("create temp root");
2467        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2468        set_member_artifact(
2469            &mut manifest,
2470            CHILD,
2471            &root,
2472            "artifacts/child",
2473            b"child-snapshot",
2474        );
2475        set_member_artifact(
2476            &mut manifest,
2477            ROOT,
2478            &root,
2479            "artifacts/root",
2480            b"root-snapshot",
2481        );
2482
2483        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2484        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
2485            .expect("dry-run should validate artifacts");
2486        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
2487        journal
2488            .mark_operation_completed(0)
2489            .expect("mark operation completed");
2490        let next = journal.next_operation();
2491
2492        fs::remove_dir_all(root).expect("remove temp root");
2493        assert!(next.ready);
2494        assert!(!next.complete);
2495        assert!(next.operation_available);
2496        let operation = next.operation.expect("next operation");
2497        assert_eq!(operation.sequence, 1);
2498        assert_eq!(operation.state, RestoreApplyOperationState::Ready);
2499        assert_eq!(operation.operation, RestoreApplyOperationKind::LoadSnapshot);
2500        assert_eq!(operation.source_canister, ROOT);
2501    }
2502
2503    // Ensure blocked journals report no next ready operation.
2504    #[test]
2505    fn apply_journal_next_operation_reports_blocked_state() {
2506        let manifest = valid_manifest(IdentityMode::Relocatable);
2507
2508        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2509        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
2510        let journal = RestoreApplyJournal::from_dry_run(&dry_run);
2511        let next = journal.next_operation();
2512
2513        assert!(!next.ready);
2514        assert!(!next.operation_available);
2515        assert!(next.operation.is_none());
2516        assert!(
2517            next.blocked_reasons
2518                .contains(&"missing-artifact-validation".to_string())
2519        );
2520    }
2521
2522    // Ensure apply journal validation rejects inconsistent state counts.
2523    #[test]
2524    fn apply_journal_validation_rejects_count_mismatch() {
2525        let manifest = valid_manifest(IdentityMode::Relocatable);
2526
2527        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2528        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
2529        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
2530        journal.blocked_operations = 0;
2531
2532        let err = journal.validate().expect_err("count mismatch should fail");
2533
2534        assert!(matches!(
2535            err,
2536            RestoreApplyJournalError::CountMismatch {
2537                field: "blocked_operations",
2538                ..
2539            }
2540        ));
2541    }
2542
2543    // Ensure apply journal validation rejects duplicate operation sequences.
2544    #[test]
2545    fn apply_journal_validation_rejects_duplicate_sequences() {
2546        let manifest = valid_manifest(IdentityMode::Relocatable);
2547
2548        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2549        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
2550        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
2551        journal.operations[1].sequence = journal.operations[0].sequence;
2552
2553        let err = journal
2554            .validate()
2555            .expect_err("duplicate sequence should fail");
2556
2557        assert!(matches!(
2558            err,
2559            RestoreApplyJournalError::DuplicateSequence(0)
2560        ));
2561    }
2562
2563    // Ensure failed journal operations must explain why execution failed.
2564    #[test]
2565    fn apply_journal_validation_rejects_failed_without_reason() {
2566        let manifest = valid_manifest(IdentityMode::Relocatable);
2567
2568        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2569        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
2570        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
2571        journal.operations[0].state = RestoreApplyOperationState::Failed;
2572        journal.operations[0].blocking_reasons = Vec::new();
2573        journal.blocked_operations -= 1;
2574        journal.failed_operations = 1;
2575
2576        let err = journal
2577            .validate()
2578            .expect_err("failed operation without reason should fail");
2579
2580        assert!(matches!(
2581            err,
2582            RestoreApplyJournalError::FailureReasonRequired(0)
2583        ));
2584    }
2585
2586    // Ensure completing a journal operation updates counts and advances status.
2587    #[test]
2588    fn apply_journal_mark_completed_advances_next_ready_operation() {
2589        let root = temp_dir("canic-restore-apply-journal-completed");
2590        fs::create_dir_all(&root).expect("create temp root");
2591        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2592        set_member_artifact(
2593            &mut manifest,
2594            CHILD,
2595            &root,
2596            "artifacts/child",
2597            b"child-snapshot",
2598        );
2599        set_member_artifact(
2600            &mut manifest,
2601            ROOT,
2602            &root,
2603            "artifacts/root",
2604            b"root-snapshot",
2605        );
2606
2607        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2608        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
2609            .expect("dry-run should validate artifacts");
2610        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
2611
2612        journal
2613            .mark_operation_completed(0)
2614            .expect("mark operation completed");
2615        let status = journal.status();
2616
2617        fs::remove_dir_all(root).expect("remove temp root");
2618        assert_eq!(
2619            journal.operations[0].state,
2620            RestoreApplyOperationState::Completed
2621        );
2622        assert_eq!(journal.completed_operations, 1);
2623        assert_eq!(journal.ready_operations, 7);
2624        assert_eq!(status.next_ready_sequence, Some(1));
2625    }
2626
2627    // Ensure failed journal operations carry a reason and update counts.
2628    #[test]
2629    fn apply_journal_mark_failed_records_reason() {
2630        let root = temp_dir("canic-restore-apply-journal-failed");
2631        fs::create_dir_all(&root).expect("create temp root");
2632        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2633        set_member_artifact(
2634            &mut manifest,
2635            CHILD,
2636            &root,
2637            "artifacts/child",
2638            b"child-snapshot",
2639        );
2640        set_member_artifact(
2641            &mut manifest,
2642            ROOT,
2643            &root,
2644            "artifacts/root",
2645            b"root-snapshot",
2646        );
2647
2648        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2649        let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
2650            .expect("dry-run should validate artifacts");
2651        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
2652
2653        journal
2654            .mark_operation_failed(0, "dfx-load-failed".to_string())
2655            .expect("mark operation failed");
2656
2657        fs::remove_dir_all(root).expect("remove temp root");
2658        assert_eq!(
2659            journal.operations[0].state,
2660            RestoreApplyOperationState::Failed
2661        );
2662        assert_eq!(
2663            journal.operations[0].blocking_reasons,
2664            vec!["dfx-load-failed".to_string()]
2665        );
2666        assert_eq!(journal.failed_operations, 1);
2667        assert_eq!(journal.ready_operations, 7);
2668    }
2669
2670    // Ensure blocked operations cannot be manually completed before blockers clear.
2671    #[test]
2672    fn apply_journal_rejects_blocked_operation_completion() {
2673        let manifest = valid_manifest(IdentityMode::Relocatable);
2674
2675        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2676        let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
2677        let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
2678
2679        let err = journal
2680            .mark_operation_completed(0)
2681            .expect_err("blocked operation should not complete");
2682
2683        assert!(matches!(
2684            err,
2685            RestoreApplyJournalError::InvalidOperationTransition { sequence: 0, .. }
2686        ));
2687    }
2688
2689    // Ensure apply dry-runs fail closed when a referenced artifact is missing.
2690    #[test]
2691    fn apply_dry_run_rejects_missing_artifacts() {
2692        let root = temp_dir("canic-restore-apply-artifacts-missing");
2693        fs::create_dir_all(&root).expect("create temp root");
2694        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2695        manifest.fleet.members[0].source_snapshot.artifact_path = "missing-child".to_string();
2696
2697        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2698        let err = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
2699            .expect_err("missing artifact should fail");
2700
2701        fs::remove_dir_all(root).expect("remove temp root");
2702        assert!(matches!(
2703            err,
2704            RestoreApplyDryRunError::ArtifactMissing { .. }
2705        ));
2706    }
2707
2708    // Ensure apply dry-runs reject artifact paths that escape the backup directory.
2709    #[test]
2710    fn apply_dry_run_rejects_artifact_path_traversal() {
2711        let root = temp_dir("canic-restore-apply-artifacts-traversal");
2712        fs::create_dir_all(&root).expect("create temp root");
2713        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2714        manifest.fleet.members[1].source_snapshot.artifact_path = "../outside".to_string();
2715
2716        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2717        let err = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
2718            .expect_err("path traversal should fail");
2719
2720        fs::remove_dir_all(root).expect("remove temp root");
2721        assert!(matches!(
2722            err,
2723            RestoreApplyDryRunError::ArtifactPathEscapesBackup { .. }
2724        ));
2725    }
2726
2727    // Ensure apply dry-runs reject status files that do not match the plan.
2728    #[test]
2729    fn apply_dry_run_rejects_mismatched_status() {
2730        let manifest = valid_manifest(IdentityMode::Relocatable);
2731
2732        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2733        let mut status = RestoreStatus::from_plan(&plan);
2734        status.backup_id = "other-backup".to_string();
2735
2736        let err = RestoreApplyDryRun::try_from_plan(&plan, Some(&status))
2737            .expect_err("mismatched status should fail");
2738
2739        assert!(matches!(
2740            err,
2741            RestoreApplyDryRunError::StatusPlanMismatch {
2742                field: "backup_id",
2743                ..
2744            }
2745        ));
2746    }
2747
2748    // Ensure role-level verification checks are counted once per matching member.
2749    #[test]
2750    fn plan_expands_role_verification_checks_per_matching_member() {
2751        let mut manifest = valid_manifest(IdentityMode::Relocatable);
2752        manifest.fleet.members.push(fleet_member(
2753            "app",
2754            CHILD_TWO,
2755            Some(ROOT),
2756            IdentityMode::Relocatable,
2757            1,
2758        ));
2759        manifest
2760            .verification
2761            .member_checks
2762            .push(MemberVerificationChecks {
2763                role: "app".to_string(),
2764                checks: vec![VerificationCheck {
2765                    kind: "app-ready".to_string(),
2766                    method: Some("ready".to_string()),
2767                    roles: Vec::new(),
2768                }],
2769            });
2770
2771        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2772
2773        assert_eq!(plan.verification_summary.fleet_checks, 0);
2774        assert_eq!(plan.verification_summary.member_check_groups, 1);
2775        assert_eq!(plan.verification_summary.member_checks, 5);
2776        assert_eq!(plan.verification_summary.members_with_checks, 3);
2777        assert_eq!(plan.verification_summary.total_checks, 5);
2778    }
2779
2780    // Ensure mapped restores must cover every source member.
2781    #[test]
2782    fn mapped_restore_requires_complete_mapping() {
2783        let manifest = valid_manifest(IdentityMode::Relocatable);
2784        let mapping = RestoreMapping {
2785            members: vec![RestoreMappingEntry {
2786                source_canister: ROOT.to_string(),
2787                target_canister: ROOT.to_string(),
2788            }],
2789        };
2790
2791        let err = RestorePlanner::plan(&manifest, Some(&mapping))
2792            .expect_err("incomplete mapping should fail");
2793
2794        assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
2795    }
2796
2797    // Ensure mappings cannot silently include canisters outside the manifest.
2798    #[test]
2799    fn mapped_restore_rejects_unknown_mapping_sources() {
2800        let manifest = valid_manifest(IdentityMode::Relocatable);
2801        let unknown = "rdmx6-jaaaa-aaaaa-aaadq-cai";
2802        let mapping = RestoreMapping {
2803            members: vec![
2804                RestoreMappingEntry {
2805                    source_canister: ROOT.to_string(),
2806                    target_canister: ROOT.to_string(),
2807                },
2808                RestoreMappingEntry {
2809                    source_canister: CHILD.to_string(),
2810                    target_canister: TARGET.to_string(),
2811                },
2812                RestoreMappingEntry {
2813                    source_canister: unknown.to_string(),
2814                    target_canister: unknown.to_string(),
2815                },
2816            ],
2817        };
2818
2819        let err = RestorePlanner::plan(&manifest, Some(&mapping))
2820            .expect_err("unknown mapping source should fail");
2821
2822        assert!(matches!(err, RestorePlanError::UnknownMappingSource(_)));
2823    }
2824
2825    // Ensure duplicate target mappings fail before a plan is produced.
2826    #[test]
2827    fn duplicate_mapping_targets_fail_validation() {
2828        let manifest = valid_manifest(IdentityMode::Relocatable);
2829        let mapping = RestoreMapping {
2830            members: vec![
2831                RestoreMappingEntry {
2832                    source_canister: ROOT.to_string(),
2833                    target_canister: ROOT.to_string(),
2834                },
2835                RestoreMappingEntry {
2836                    source_canister: CHILD.to_string(),
2837                    target_canister: ROOT.to_string(),
2838                },
2839            ],
2840        };
2841
2842        let err = RestorePlanner::plan(&manifest, Some(&mapping))
2843            .expect_err("duplicate targets should fail");
2844
2845        assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
2846    }
2847
2848    // Write one artifact and record its path and checksum in the test manifest.
2849    fn set_member_artifact(
2850        manifest: &mut FleetBackupManifest,
2851        canister_id: &str,
2852        root: &Path,
2853        artifact_path: &str,
2854        bytes: &[u8],
2855    ) {
2856        let full_path = root.join(artifact_path);
2857        fs::create_dir_all(full_path.parent().expect("artifact parent")).expect("create parent");
2858        fs::write(&full_path, bytes).expect("write artifact");
2859        let checksum = ArtifactChecksum::from_bytes(bytes);
2860        let member = manifest
2861            .fleet
2862            .members
2863            .iter_mut()
2864            .find(|member| member.canister_id == canister_id)
2865            .expect("member should exist");
2866        member.source_snapshot.artifact_path = artifact_path.to_string();
2867        member.source_snapshot.checksum = Some(checksum.hash);
2868    }
2869
2870    // Return a unique temporary directory for restore tests.
2871    fn temp_dir(name: &str) -> PathBuf {
2872        let nanos = SystemTime::now()
2873            .duration_since(UNIX_EPOCH)
2874            .expect("system time should be after epoch")
2875            .as_nanos();
2876        env::temp_dir().join(format!("{name}-{nanos}"))
2877    }
2878}