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