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