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