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