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