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
271fn validate_restore_apply_artifacts(
273 plan: &RestorePlan,
274 backup_root: &Path,
275) -> Result<RestoreApplyArtifactValidation, RestoreApplyDryRunError> {
276 let mut checks = Vec::new();
277
278 for member in plan.ordered_members() {
279 checks.push(validate_restore_apply_artifact(member, backup_root)?);
280 }
281
282 let members_with_expected_checksums = checks
283 .iter()
284 .filter(|check| check.checksum_expected.is_some())
285 .count();
286 let artifacts_present = checks.iter().all(|check| check.exists);
287 let checksums_verified = members_with_expected_checksums == plan.member_count
288 && checks.iter().all(|check| check.checksum_verified);
289
290 Ok(RestoreApplyArtifactValidation {
291 backup_root: backup_root.to_string_lossy().to_string(),
292 checked_members: checks.len(),
293 artifacts_present,
294 checksums_verified,
295 members_with_expected_checksums,
296 checks,
297 })
298}
299
300fn validate_restore_apply_artifact(
302 member: &RestorePlanMember,
303 backup_root: &Path,
304) -> Result<RestoreApplyArtifactCheck, RestoreApplyDryRunError> {
305 let artifact_path = safe_restore_artifact_path(
306 &member.source_canister,
307 &member.source_snapshot.artifact_path,
308 )?;
309 let resolved_path = backup_root.join(&artifact_path);
310
311 if !resolved_path.exists() {
312 return Err(RestoreApplyDryRunError::ArtifactMissing {
313 source_canister: member.source_canister.clone(),
314 artifact_path: member.source_snapshot.artifact_path.clone(),
315 resolved_path: resolved_path.to_string_lossy().to_string(),
316 });
317 }
318
319 let (checksum_actual, checksum_verified) =
320 if let Some(expected) = &member.source_snapshot.checksum {
321 let checksum = ArtifactChecksum::from_path(&resolved_path).map_err(|source| {
322 RestoreApplyDryRunError::ArtifactChecksum {
323 source_canister: member.source_canister.clone(),
324 artifact_path: member.source_snapshot.artifact_path.clone(),
325 source,
326 }
327 })?;
328 checksum.verify(expected).map_err(|source| {
329 RestoreApplyDryRunError::ArtifactChecksum {
330 source_canister: member.source_canister.clone(),
331 artifact_path: member.source_snapshot.artifact_path.clone(),
332 source,
333 }
334 })?;
335 (Some(checksum.hash), true)
336 } else {
337 (None, false)
338 };
339
340 Ok(RestoreApplyArtifactCheck {
341 source_canister: member.source_canister.clone(),
342 target_canister: member.target_canister.clone(),
343 snapshot_id: member.source_snapshot.snapshot_id.clone(),
344 artifact_path: member.source_snapshot.artifact_path.clone(),
345 resolved_path: resolved_path.to_string_lossy().to_string(),
346 exists: true,
347 checksum_algorithm: member.source_snapshot.checksum_algorithm.clone(),
348 checksum_expected: member.source_snapshot.checksum.clone(),
349 checksum_actual,
350 checksum_verified,
351 })
352}
353
354fn safe_restore_artifact_path(
356 source_canister: &str,
357 artifact_path: &str,
358) -> Result<PathBuf, RestoreApplyDryRunError> {
359 let path = Path::new(artifact_path);
360 let is_safe = path
361 .components()
362 .all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
363
364 if is_safe {
365 return Ok(path.to_path_buf());
366 }
367
368 Err(RestoreApplyDryRunError::ArtifactPathEscapesBackup {
369 source_canister: source_canister.to_string(),
370 artifact_path: artifact_path.to_string(),
371 })
372}
373
374fn validate_restore_status_matches_plan(
376 plan: &RestorePlan,
377 status: &RestoreStatus,
378) -> Result<(), RestoreApplyDryRunError> {
379 validate_status_string_field("backup_id", &plan.backup_id, &status.backup_id)?;
380 validate_status_string_field(
381 "source_environment",
382 &plan.source_environment,
383 &status.source_environment,
384 )?;
385 validate_status_string_field(
386 "source_root_canister",
387 &plan.source_root_canister,
388 &status.source_root_canister,
389 )?;
390 validate_status_string_field("topology_hash", &plan.topology_hash, &status.topology_hash)?;
391 validate_status_usize_field("member_count", plan.member_count, status.member_count)?;
392 validate_status_usize_field(
393 "phase_count",
394 plan.ordering_summary.phase_count,
395 status.phase_count,
396 )?;
397 Ok(())
398}
399
400fn validate_status_string_field(
402 field: &'static str,
403 plan: &str,
404 status: &str,
405) -> Result<(), RestoreApplyDryRunError> {
406 if plan == status {
407 return Ok(());
408 }
409
410 Err(RestoreApplyDryRunError::StatusPlanMismatch {
411 field,
412 plan: plan.to_string(),
413 status: status.to_string(),
414 })
415}
416
417const fn validate_status_usize_field(
419 field: &'static str,
420 plan: usize,
421 status: usize,
422) -> Result<(), RestoreApplyDryRunError> {
423 if plan == status {
424 return Ok(());
425 }
426
427 Err(RestoreApplyDryRunError::StatusPlanCountMismatch {
428 field,
429 plan,
430 status,
431 })
432}
433
434#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
439pub struct RestoreApplyArtifactValidation {
440 pub backup_root: String,
441 pub checked_members: usize,
442 pub artifacts_present: bool,
443 pub checksums_verified: bool,
444 pub members_with_expected_checksums: usize,
445 pub checks: Vec<RestoreApplyArtifactCheck>,
446}
447
448#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
453pub struct RestoreApplyArtifactCheck {
454 pub source_canister: String,
455 pub target_canister: String,
456 pub snapshot_id: String,
457 pub artifact_path: String,
458 pub resolved_path: String,
459 pub exists: bool,
460 pub checksum_algorithm: String,
461 pub checksum_expected: Option<String>,
462 pub checksum_actual: Option<String>,
463 pub checksum_verified: bool,
464}
465
466#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
471pub struct RestoreApplyDryRunPhase {
472 pub restore_group: u16,
473 pub operations: Vec<RestoreApplyDryRunOperation>,
474}
475
476impl RestoreApplyDryRunPhase {
477 fn from_plan_phase(phase: &RestorePhase, next_sequence: &mut usize) -> Self {
479 let mut operations = Vec::new();
480
481 for member in &phase.members {
482 push_member_operation(
483 &mut operations,
484 next_sequence,
485 RestoreApplyOperationKind::UploadSnapshot,
486 member,
487 None,
488 );
489 push_member_operation(
490 &mut operations,
491 next_sequence,
492 RestoreApplyOperationKind::LoadSnapshot,
493 member,
494 None,
495 );
496 push_member_operation(
497 &mut operations,
498 next_sequence,
499 RestoreApplyOperationKind::ReinstallCode,
500 member,
501 None,
502 );
503
504 for check in &member.verification_checks {
505 push_member_operation(
506 &mut operations,
507 next_sequence,
508 RestoreApplyOperationKind::VerifyMember,
509 member,
510 Some(check),
511 );
512 }
513 }
514
515 Self {
516 restore_group: phase.restore_group,
517 operations,
518 }
519 }
520}
521
522fn push_member_operation(
524 operations: &mut Vec<RestoreApplyDryRunOperation>,
525 next_sequence: &mut usize,
526 operation: RestoreApplyOperationKind,
527 member: &RestorePlanMember,
528 check: Option<&VerificationCheck>,
529) {
530 let sequence = *next_sequence;
531 *next_sequence += 1;
532
533 operations.push(RestoreApplyDryRunOperation {
534 sequence,
535 operation,
536 restore_group: member.restore_group,
537 phase_order: member.phase_order,
538 source_canister: member.source_canister.clone(),
539 target_canister: member.target_canister.clone(),
540 role: member.role.clone(),
541 snapshot_id: Some(member.source_snapshot.snapshot_id.clone()),
542 artifact_path: Some(member.source_snapshot.artifact_path.clone()),
543 verification_kind: check.map(|check| check.kind.clone()),
544 verification_method: check.and_then(|check| check.method.clone()),
545 });
546}
547
548#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
553pub struct RestoreApplyDryRunOperation {
554 pub sequence: usize,
555 pub operation: RestoreApplyOperationKind,
556 pub restore_group: u16,
557 pub phase_order: usize,
558 pub source_canister: String,
559 pub target_canister: String,
560 pub role: String,
561 pub snapshot_id: Option<String>,
562 pub artifact_path: Option<String>,
563 pub verification_kind: Option<String>,
564 pub verification_method: Option<String>,
565}
566
567#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
572#[serde(rename_all = "kebab-case")]
573pub enum RestoreApplyOperationKind {
574 UploadSnapshot,
575 LoadSnapshot,
576 ReinstallCode,
577 VerifyMember,
578}
579
580#[derive(Debug, ThisError)]
585pub enum RestoreApplyDryRunError {
586 #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
587 StatusPlanMismatch {
588 field: &'static str,
589 plan: String,
590 status: String,
591 },
592
593 #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
594 StatusPlanCountMismatch {
595 field: &'static str,
596 plan: usize,
597 status: usize,
598 },
599
600 #[error("restore artifact path for {source_canister} escapes backup root: {artifact_path}")]
601 ArtifactPathEscapesBackup {
602 source_canister: String,
603 artifact_path: String,
604 },
605
606 #[error(
607 "restore artifact for {source_canister} is missing: {artifact_path} at {resolved_path}"
608 )]
609 ArtifactMissing {
610 source_canister: String,
611 artifact_path: String,
612 resolved_path: String,
613 },
614
615 #[error("restore artifact checksum failed for {source_canister} at {artifact_path}: {source}")]
616 ArtifactChecksum {
617 source_canister: String,
618 artifact_path: String,
619 #[source]
620 source: ArtifactChecksumError,
621 },
622}
623
624#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
629pub struct RestoreIdentitySummary {
630 pub mapping_supplied: bool,
631 pub all_sources_mapped: bool,
632 pub fixed_members: usize,
633 pub relocatable_members: usize,
634 pub in_place_members: usize,
635 pub mapped_members: usize,
636 pub remapped_members: usize,
637}
638
639#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
644#[expect(
645 clippy::struct_excessive_bools,
646 reason = "restore summaries intentionally expose machine-readable readiness flags"
647)]
648pub struct RestoreSnapshotSummary {
649 pub all_members_have_module_hash: bool,
650 pub all_members_have_wasm_hash: bool,
651 pub all_members_have_code_version: bool,
652 pub all_members_have_checksum: bool,
653 pub members_with_module_hash: usize,
654 pub members_with_wasm_hash: usize,
655 pub members_with_code_version: usize,
656 pub members_with_checksum: usize,
657}
658
659#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
664pub struct RestoreVerificationSummary {
665 pub verification_required: bool,
666 pub all_members_have_checks: bool,
667 pub fleet_checks: usize,
668 pub member_check_groups: usize,
669 pub member_checks: usize,
670 pub members_with_checks: usize,
671 pub total_checks: usize,
672}
673
674#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
679pub struct RestoreReadinessSummary {
680 pub ready: bool,
681 pub reasons: Vec<String>,
682}
683
684#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
689pub struct RestoreOperationSummary {
690 pub planned_snapshot_loads: usize,
691 pub planned_code_reinstalls: usize,
692 pub planned_verification_checks: usize,
693 pub planned_phases: usize,
694}
695
696#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
701pub struct RestoreOrderingSummary {
702 pub phase_count: usize,
703 pub dependency_free_members: usize,
704 pub in_group_parent_edges: usize,
705 pub cross_group_parent_edges: usize,
706}
707
708#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
713pub struct RestorePhase {
714 pub restore_group: u16,
715 pub members: Vec<RestorePlanMember>,
716}
717
718#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
723pub struct RestorePlanMember {
724 pub source_canister: String,
725 pub target_canister: String,
726 pub role: String,
727 pub parent_source_canister: Option<String>,
728 pub parent_target_canister: Option<String>,
729 pub ordering_dependency: Option<RestoreOrderingDependency>,
730 pub phase_order: usize,
731 pub restore_group: u16,
732 pub identity_mode: IdentityMode,
733 pub verification_class: String,
734 pub verification_checks: Vec<VerificationCheck>,
735 pub source_snapshot: SourceSnapshot,
736}
737
738#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
743pub struct RestoreOrderingDependency {
744 pub source_canister: String,
745 pub target_canister: String,
746 pub relationship: RestoreOrderingRelationship,
747}
748
749#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
754#[serde(rename_all = "kebab-case")]
755pub enum RestoreOrderingRelationship {
756 ParentInSameGroup,
757 ParentInEarlierGroup,
758}
759
760pub struct RestorePlanner;
765
766impl RestorePlanner {
767 pub fn plan(
769 manifest: &FleetBackupManifest,
770 mapping: Option<&RestoreMapping>,
771 ) -> Result<RestorePlan, RestorePlanError> {
772 manifest.validate()?;
773 if let Some(mapping) = mapping {
774 validate_mapping(mapping)?;
775 validate_mapping_sources(manifest, mapping)?;
776 }
777
778 let members = resolve_members(manifest, mapping)?;
779 let identity_summary = restore_identity_summary(&members, mapping.is_some());
780 let snapshot_summary = restore_snapshot_summary(&members);
781 let verification_summary = restore_verification_summary(manifest, &members);
782 let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
783 validate_restore_group_dependencies(&members)?;
784 let phases = group_and_order_members(members)?;
785 let ordering_summary = restore_ordering_summary(&phases);
786 let operation_summary =
787 restore_operation_summary(manifest.fleet.members.len(), &verification_summary, &phases);
788
789 Ok(RestorePlan {
790 backup_id: manifest.backup_id.clone(),
791 source_environment: manifest.source.environment.clone(),
792 source_root_canister: manifest.source.root_canister.clone(),
793 topology_hash: manifest.fleet.topology_hash.clone(),
794 member_count: manifest.fleet.members.len(),
795 identity_summary,
796 snapshot_summary,
797 verification_summary,
798 readiness_summary,
799 operation_summary,
800 ordering_summary,
801 phases,
802 })
803 }
804}
805
806#[derive(Debug, ThisError)]
811pub enum RestorePlanError {
812 #[error(transparent)]
813 InvalidManifest(#[from] ManifestValidationError),
814
815 #[error("field {field} must be a valid principal: {value}")]
816 InvalidPrincipal { field: &'static str, value: String },
817
818 #[error("mapping contains duplicate source canister {0}")]
819 DuplicateMappingSource(String),
820
821 #[error("mapping contains duplicate target canister {0}")]
822 DuplicateMappingTarget(String),
823
824 #[error("mapping references unknown source canister {0}")]
825 UnknownMappingSource(String),
826
827 #[error("mapping is missing source canister {0}")]
828 MissingMappingSource(String),
829
830 #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
831 FixedIdentityRemap {
832 source_canister: String,
833 target_canister: String,
834 },
835
836 #[error("restore plan contains duplicate target canister {0}")]
837 DuplicatePlanTarget(String),
838
839 #[error("restore group {0} contains a parent cycle or unresolved dependency")]
840 RestoreOrderCycle(u16),
841
842 #[error(
843 "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
844 )]
845 ParentRestoreGroupAfterChild {
846 child_source_canister: String,
847 parent_source_canister: String,
848 child_restore_group: u16,
849 parent_restore_group: u16,
850 },
851}
852
853fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
855 let mut sources = BTreeSet::new();
856 let mut targets = BTreeSet::new();
857
858 for entry in &mapping.members {
859 validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
860 validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
861
862 if !sources.insert(entry.source_canister.clone()) {
863 return Err(RestorePlanError::DuplicateMappingSource(
864 entry.source_canister.clone(),
865 ));
866 }
867
868 if !targets.insert(entry.target_canister.clone()) {
869 return Err(RestorePlanError::DuplicateMappingTarget(
870 entry.target_canister.clone(),
871 ));
872 }
873 }
874
875 Ok(())
876}
877
878fn validate_mapping_sources(
880 manifest: &FleetBackupManifest,
881 mapping: &RestoreMapping,
882) -> Result<(), RestorePlanError> {
883 let sources = manifest
884 .fleet
885 .members
886 .iter()
887 .map(|member| member.canister_id.as_str())
888 .collect::<BTreeSet<_>>();
889
890 for entry in &mapping.members {
891 if !sources.contains(entry.source_canister.as_str()) {
892 return Err(RestorePlanError::UnknownMappingSource(
893 entry.source_canister.clone(),
894 ));
895 }
896 }
897
898 Ok(())
899}
900
901fn resolve_members(
903 manifest: &FleetBackupManifest,
904 mapping: Option<&RestoreMapping>,
905) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
906 let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
907 let mut targets = BTreeSet::new();
908 let mut source_to_target = BTreeMap::new();
909
910 for member in &manifest.fleet.members {
911 let target = resolve_target(member, mapping)?;
912 if !targets.insert(target.clone()) {
913 return Err(RestorePlanError::DuplicatePlanTarget(target));
914 }
915
916 source_to_target.insert(member.canister_id.clone(), target.clone());
917 plan_members.push(RestorePlanMember {
918 source_canister: member.canister_id.clone(),
919 target_canister: target,
920 role: member.role.clone(),
921 parent_source_canister: member.parent_canister_id.clone(),
922 parent_target_canister: None,
923 ordering_dependency: None,
924 phase_order: 0,
925 restore_group: member.restore_group,
926 identity_mode: member.identity_mode.clone(),
927 verification_class: member.verification_class.clone(),
928 verification_checks: member.verification_checks.clone(),
929 source_snapshot: member.source_snapshot.clone(),
930 });
931 }
932
933 for member in &mut plan_members {
934 member.parent_target_canister = member
935 .parent_source_canister
936 .as_ref()
937 .and_then(|parent| source_to_target.get(parent))
938 .cloned();
939 }
940
941 Ok(plan_members)
942}
943
944fn resolve_target(
946 member: &FleetMember,
947 mapping: Option<&RestoreMapping>,
948) -> Result<String, RestorePlanError> {
949 let target = match mapping {
950 Some(mapping) => mapping
951 .target_for(&member.canister_id)
952 .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
953 .to_string(),
954 None => member.canister_id.clone(),
955 };
956
957 if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
958 return Err(RestorePlanError::FixedIdentityRemap {
959 source_canister: member.canister_id.clone(),
960 target_canister: target,
961 });
962 }
963
964 Ok(target)
965}
966
967fn restore_identity_summary(
969 members: &[RestorePlanMember],
970 mapping_supplied: bool,
971) -> RestoreIdentitySummary {
972 let mut summary = RestoreIdentitySummary {
973 mapping_supplied,
974 all_sources_mapped: false,
975 fixed_members: 0,
976 relocatable_members: 0,
977 in_place_members: 0,
978 mapped_members: 0,
979 remapped_members: 0,
980 };
981
982 for member in members {
983 match member.identity_mode {
984 IdentityMode::Fixed => summary.fixed_members += 1,
985 IdentityMode::Relocatable => summary.relocatable_members += 1,
986 }
987
988 if member.source_canister == member.target_canister {
989 summary.in_place_members += 1;
990 } else {
991 summary.remapped_members += 1;
992 }
993 if mapping_supplied {
994 summary.mapped_members += 1;
995 }
996 }
997
998 summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
999
1000 summary
1001}
1002
1003fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
1005 let members_with_module_hash = members
1006 .iter()
1007 .filter(|member| member.source_snapshot.module_hash.is_some())
1008 .count();
1009 let members_with_wasm_hash = members
1010 .iter()
1011 .filter(|member| member.source_snapshot.wasm_hash.is_some())
1012 .count();
1013 let members_with_code_version = members
1014 .iter()
1015 .filter(|member| member.source_snapshot.code_version.is_some())
1016 .count();
1017 let members_with_checksum = members
1018 .iter()
1019 .filter(|member| member.source_snapshot.checksum.is_some())
1020 .count();
1021
1022 RestoreSnapshotSummary {
1023 all_members_have_module_hash: members_with_module_hash == members.len(),
1024 all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
1025 all_members_have_code_version: members_with_code_version == members.len(),
1026 all_members_have_checksum: members_with_checksum == members.len(),
1027 members_with_module_hash,
1028 members_with_wasm_hash,
1029 members_with_code_version,
1030 members_with_checksum,
1031 }
1032}
1033
1034fn restore_readiness_summary(
1036 snapshot: &RestoreSnapshotSummary,
1037 verification: &RestoreVerificationSummary,
1038) -> RestoreReadinessSummary {
1039 let mut reasons = Vec::new();
1040
1041 if !snapshot.all_members_have_module_hash {
1042 reasons.push("missing-module-hash".to_string());
1043 }
1044 if !snapshot.all_members_have_wasm_hash {
1045 reasons.push("missing-wasm-hash".to_string());
1046 }
1047 if !snapshot.all_members_have_code_version {
1048 reasons.push("missing-code-version".to_string());
1049 }
1050 if !snapshot.all_members_have_checksum {
1051 reasons.push("missing-snapshot-checksum".to_string());
1052 }
1053 if !verification.all_members_have_checks {
1054 reasons.push("missing-verification-checks".to_string());
1055 }
1056
1057 RestoreReadinessSummary {
1058 ready: reasons.is_empty(),
1059 reasons,
1060 }
1061}
1062
1063fn restore_verification_summary(
1065 manifest: &FleetBackupManifest,
1066 members: &[RestorePlanMember],
1067) -> RestoreVerificationSummary {
1068 let fleet_checks = manifest.verification.fleet_checks.len();
1069 let member_check_groups = manifest.verification.member_checks.len();
1070 let role_check_counts = manifest
1071 .verification
1072 .member_checks
1073 .iter()
1074 .map(|group| (group.role.as_str(), group.checks.len()))
1075 .collect::<BTreeMap<_, _>>();
1076 let inline_member_checks = members
1077 .iter()
1078 .map(|member| member.verification_checks.len())
1079 .sum::<usize>();
1080 let role_member_checks = members
1081 .iter()
1082 .map(|member| {
1083 role_check_counts
1084 .get(member.role.as_str())
1085 .copied()
1086 .unwrap_or(0)
1087 })
1088 .sum::<usize>();
1089 let member_checks = inline_member_checks + role_member_checks;
1090 let members_with_checks = members
1091 .iter()
1092 .filter(|member| {
1093 !member.verification_checks.is_empty()
1094 || role_check_counts.contains_key(member.role.as_str())
1095 })
1096 .count();
1097
1098 RestoreVerificationSummary {
1099 verification_required: true,
1100 all_members_have_checks: members_with_checks == members.len(),
1101 fleet_checks,
1102 member_check_groups,
1103 member_checks,
1104 members_with_checks,
1105 total_checks: fleet_checks + member_checks,
1106 }
1107}
1108
1109const fn restore_operation_summary(
1111 member_count: usize,
1112 verification_summary: &RestoreVerificationSummary,
1113 phases: &[RestorePhase],
1114) -> RestoreOperationSummary {
1115 RestoreOperationSummary {
1116 planned_snapshot_loads: member_count,
1117 planned_code_reinstalls: member_count,
1118 planned_verification_checks: verification_summary.total_checks,
1119 planned_phases: phases.len(),
1120 }
1121}
1122
1123fn validate_restore_group_dependencies(
1125 members: &[RestorePlanMember],
1126) -> Result<(), RestorePlanError> {
1127 let groups_by_source = members
1128 .iter()
1129 .map(|member| (member.source_canister.as_str(), member.restore_group))
1130 .collect::<BTreeMap<_, _>>();
1131
1132 for member in members {
1133 let Some(parent) = &member.parent_source_canister else {
1134 continue;
1135 };
1136 let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
1137 continue;
1138 };
1139
1140 if *parent_group > member.restore_group {
1141 return Err(RestorePlanError::ParentRestoreGroupAfterChild {
1142 child_source_canister: member.source_canister.clone(),
1143 parent_source_canister: parent.clone(),
1144 child_restore_group: member.restore_group,
1145 parent_restore_group: *parent_group,
1146 });
1147 }
1148 }
1149
1150 Ok(())
1151}
1152
1153fn group_and_order_members(
1155 members: Vec<RestorePlanMember>,
1156) -> Result<Vec<RestorePhase>, RestorePlanError> {
1157 let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
1158 for member in members {
1159 groups.entry(member.restore_group).or_default().push(member);
1160 }
1161
1162 groups
1163 .into_iter()
1164 .map(|(restore_group, members)| {
1165 let members = order_group(restore_group, members)?;
1166 Ok(RestorePhase {
1167 restore_group,
1168 members,
1169 })
1170 })
1171 .collect()
1172}
1173
1174fn order_group(
1176 restore_group: u16,
1177 members: Vec<RestorePlanMember>,
1178) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
1179 let mut remaining = members;
1180 let group_sources = remaining
1181 .iter()
1182 .map(|member| member.source_canister.clone())
1183 .collect::<BTreeSet<_>>();
1184 let mut emitted = BTreeSet::new();
1185 let mut ordered = Vec::with_capacity(remaining.len());
1186
1187 while !remaining.is_empty() {
1188 let Some(index) = remaining
1189 .iter()
1190 .position(|member| parent_satisfied(member, &group_sources, &emitted))
1191 else {
1192 return Err(RestorePlanError::RestoreOrderCycle(restore_group));
1193 };
1194
1195 let mut member = remaining.remove(index);
1196 member.phase_order = ordered.len();
1197 member.ordering_dependency = ordering_dependency(&member, &group_sources);
1198 emitted.insert(member.source_canister.clone());
1199 ordered.push(member);
1200 }
1201
1202 Ok(ordered)
1203}
1204
1205fn ordering_dependency(
1207 member: &RestorePlanMember,
1208 group_sources: &BTreeSet<String>,
1209) -> Option<RestoreOrderingDependency> {
1210 let parent_source = member.parent_source_canister.as_ref()?;
1211 let parent_target = member.parent_target_canister.as_ref()?;
1212 let relationship = if group_sources.contains(parent_source) {
1213 RestoreOrderingRelationship::ParentInSameGroup
1214 } else {
1215 RestoreOrderingRelationship::ParentInEarlierGroup
1216 };
1217
1218 Some(RestoreOrderingDependency {
1219 source_canister: parent_source.clone(),
1220 target_canister: parent_target.clone(),
1221 relationship,
1222 })
1223}
1224
1225fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
1227 let mut summary = RestoreOrderingSummary {
1228 phase_count: phases.len(),
1229 dependency_free_members: 0,
1230 in_group_parent_edges: 0,
1231 cross_group_parent_edges: 0,
1232 };
1233
1234 for member in phases.iter().flat_map(|phase| phase.members.iter()) {
1235 match &member.ordering_dependency {
1236 Some(dependency)
1237 if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
1238 {
1239 summary.in_group_parent_edges += 1;
1240 }
1241 Some(dependency)
1242 if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
1243 {
1244 summary.cross_group_parent_edges += 1;
1245 }
1246 Some(_) => {}
1247 None => summary.dependency_free_members += 1,
1248 }
1249 }
1250
1251 summary
1252}
1253
1254fn parent_satisfied(
1256 member: &RestorePlanMember,
1257 group_sources: &BTreeSet<String>,
1258 emitted: &BTreeSet<String>,
1259) -> bool {
1260 match &member.parent_source_canister {
1261 Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
1262 _ => true,
1263 }
1264}
1265
1266fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
1268 Principal::from_str(value)
1269 .map(|_| ())
1270 .map_err(|_| RestorePlanError::InvalidPrincipal {
1271 field,
1272 value: value.to_string(),
1273 })
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278 use super::*;
1279 use crate::manifest::{
1280 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetSection,
1281 MemberVerificationChecks, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
1282 VerificationPlan,
1283 };
1284 use std::{
1285 env, fs,
1286 path::{Path, PathBuf},
1287 time::{SystemTime, UNIX_EPOCH},
1288 };
1289
1290 const ROOT: &str = "aaaaa-aa";
1291 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
1292 const CHILD_TWO: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";
1293 const TARGET: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
1294 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1295
1296 fn valid_manifest(identity_mode: IdentityMode) -> FleetBackupManifest {
1298 FleetBackupManifest {
1299 manifest_version: 1,
1300 backup_id: "fbk_test_001".to_string(),
1301 created_at: "2026-04-10T12:00:00Z".to_string(),
1302 tool: ToolMetadata {
1303 name: "canic".to_string(),
1304 version: "v1".to_string(),
1305 },
1306 source: SourceMetadata {
1307 environment: "local".to_string(),
1308 root_canister: ROOT.to_string(),
1309 },
1310 consistency: ConsistencySection {
1311 mode: ConsistencyMode::CrashConsistent,
1312 backup_units: vec![BackupUnit {
1313 unit_id: "whole-fleet".to_string(),
1314 kind: BackupUnitKind::WholeFleet,
1315 roles: vec!["root".to_string(), "app".to_string()],
1316 consistency_reason: None,
1317 dependency_closure: Vec::new(),
1318 topology_validation: "subtree-closed".to_string(),
1319 quiescence_strategy: None,
1320 }],
1321 },
1322 fleet: FleetSection {
1323 topology_hash_algorithm: "sha256".to_string(),
1324 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1325 discovery_topology_hash: HASH.to_string(),
1326 pre_snapshot_topology_hash: HASH.to_string(),
1327 topology_hash: HASH.to_string(),
1328 members: vec![
1329 fleet_member("app", CHILD, Some(ROOT), identity_mode, 1),
1330 fleet_member("root", ROOT, None, IdentityMode::Fixed, 1),
1331 ],
1332 },
1333 verification: VerificationPlan {
1334 fleet_checks: Vec::new(),
1335 member_checks: Vec::new(),
1336 },
1337 }
1338 }
1339
1340 fn fleet_member(
1342 role: &str,
1343 canister_id: &str,
1344 parent_canister_id: Option<&str>,
1345 identity_mode: IdentityMode,
1346 restore_group: u16,
1347 ) -> FleetMember {
1348 FleetMember {
1349 role: role.to_string(),
1350 canister_id: canister_id.to_string(),
1351 parent_canister_id: parent_canister_id.map(str::to_string),
1352 subnet_canister_id: None,
1353 controller_hint: Some(ROOT.to_string()),
1354 identity_mode,
1355 restore_group,
1356 verification_class: "basic".to_string(),
1357 verification_checks: vec![VerificationCheck {
1358 kind: "call".to_string(),
1359 method: Some("canic_ready".to_string()),
1360 roles: Vec::new(),
1361 }],
1362 source_snapshot: SourceSnapshot {
1363 snapshot_id: format!("snap-{role}"),
1364 module_hash: Some(HASH.to_string()),
1365 wasm_hash: Some(HASH.to_string()),
1366 code_version: Some("v0.30.0".to_string()),
1367 artifact_path: format!("artifacts/{role}"),
1368 checksum_algorithm: "sha256".to_string(),
1369 checksum: Some(HASH.to_string()),
1370 },
1371 }
1372 }
1373
1374 #[test]
1376 fn in_place_plan_orders_parent_before_child() {
1377 let manifest = valid_manifest(IdentityMode::Relocatable);
1378
1379 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1380 let ordered = plan.ordered_members();
1381
1382 assert_eq!(plan.backup_id, "fbk_test_001");
1383 assert_eq!(plan.source_environment, "local");
1384 assert_eq!(plan.source_root_canister, ROOT);
1385 assert_eq!(plan.topology_hash, HASH);
1386 assert_eq!(plan.member_count, 2);
1387 assert_eq!(plan.identity_summary.fixed_members, 1);
1388 assert_eq!(plan.identity_summary.relocatable_members, 1);
1389 assert_eq!(plan.identity_summary.in_place_members, 2);
1390 assert_eq!(plan.identity_summary.mapped_members, 0);
1391 assert_eq!(plan.identity_summary.remapped_members, 0);
1392 assert!(plan.verification_summary.verification_required);
1393 assert!(plan.verification_summary.all_members_have_checks);
1394 assert!(plan.readiness_summary.ready);
1395 assert!(plan.readiness_summary.reasons.is_empty());
1396 assert_eq!(plan.verification_summary.fleet_checks, 0);
1397 assert_eq!(plan.verification_summary.member_check_groups, 0);
1398 assert_eq!(plan.verification_summary.member_checks, 2);
1399 assert_eq!(plan.verification_summary.members_with_checks, 2);
1400 assert_eq!(plan.verification_summary.total_checks, 2);
1401 assert_eq!(plan.ordering_summary.phase_count, 1);
1402 assert_eq!(plan.ordering_summary.dependency_free_members, 1);
1403 assert_eq!(plan.ordering_summary.in_group_parent_edges, 1);
1404 assert_eq!(plan.ordering_summary.cross_group_parent_edges, 0);
1405 assert_eq!(ordered[0].phase_order, 0);
1406 assert_eq!(ordered[1].phase_order, 1);
1407 assert_eq!(ordered[0].source_canister, ROOT);
1408 assert_eq!(ordered[1].source_canister, CHILD);
1409 assert_eq!(
1410 ordered[1].ordering_dependency,
1411 Some(RestoreOrderingDependency {
1412 source_canister: ROOT.to_string(),
1413 target_canister: ROOT.to_string(),
1414 relationship: RestoreOrderingRelationship::ParentInSameGroup,
1415 })
1416 );
1417 }
1418
1419 #[test]
1421 fn plan_reports_parent_dependency_from_earlier_group() {
1422 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1423 manifest.fleet.members[0].restore_group = 2;
1424 manifest.fleet.members[1].restore_group = 1;
1425
1426 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1427 let ordered = plan.ordered_members();
1428
1429 assert_eq!(plan.phases.len(), 2);
1430 assert_eq!(plan.ordering_summary.phase_count, 2);
1431 assert_eq!(plan.ordering_summary.dependency_free_members, 1);
1432 assert_eq!(plan.ordering_summary.in_group_parent_edges, 0);
1433 assert_eq!(plan.ordering_summary.cross_group_parent_edges, 1);
1434 assert_eq!(ordered[0].source_canister, ROOT);
1435 assert_eq!(ordered[1].source_canister, CHILD);
1436 assert_eq!(
1437 ordered[1].ordering_dependency,
1438 Some(RestoreOrderingDependency {
1439 source_canister: ROOT.to_string(),
1440 target_canister: ROOT.to_string(),
1441 relationship: RestoreOrderingRelationship::ParentInEarlierGroup,
1442 })
1443 );
1444 }
1445
1446 #[test]
1448 fn plan_rejects_parent_in_later_restore_group() {
1449 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1450 manifest.fleet.members[0].restore_group = 1;
1451 manifest.fleet.members[1].restore_group = 2;
1452
1453 let err = RestorePlanner::plan(&manifest, None)
1454 .expect_err("parent-after-child group ordering should fail");
1455
1456 assert!(matches!(
1457 err,
1458 RestorePlanError::ParentRestoreGroupAfterChild { .. }
1459 ));
1460 }
1461
1462 #[test]
1464 fn fixed_identity_member_cannot_be_remapped() {
1465 let manifest = valid_manifest(IdentityMode::Fixed);
1466 let mapping = RestoreMapping {
1467 members: vec![
1468 RestoreMappingEntry {
1469 source_canister: ROOT.to_string(),
1470 target_canister: ROOT.to_string(),
1471 },
1472 RestoreMappingEntry {
1473 source_canister: CHILD.to_string(),
1474 target_canister: TARGET.to_string(),
1475 },
1476 ],
1477 };
1478
1479 let err = RestorePlanner::plan(&manifest, Some(&mapping))
1480 .expect_err("fixed member remap should fail");
1481
1482 assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
1483 }
1484
1485 #[test]
1487 fn relocatable_member_can_be_mapped() {
1488 let manifest = valid_manifest(IdentityMode::Relocatable);
1489 let mapping = RestoreMapping {
1490 members: vec![
1491 RestoreMappingEntry {
1492 source_canister: ROOT.to_string(),
1493 target_canister: ROOT.to_string(),
1494 },
1495 RestoreMappingEntry {
1496 source_canister: CHILD.to_string(),
1497 target_canister: TARGET.to_string(),
1498 },
1499 ],
1500 };
1501
1502 let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
1503 let child = plan
1504 .ordered_members()
1505 .into_iter()
1506 .find(|member| member.source_canister == CHILD)
1507 .expect("child member should be planned");
1508
1509 assert_eq!(plan.identity_summary.fixed_members, 1);
1510 assert_eq!(plan.identity_summary.relocatable_members, 1);
1511 assert_eq!(plan.identity_summary.in_place_members, 1);
1512 assert_eq!(plan.identity_summary.mapped_members, 2);
1513 assert_eq!(plan.identity_summary.remapped_members, 1);
1514 assert_eq!(child.target_canister, TARGET);
1515 assert_eq!(child.parent_target_canister, Some(ROOT.to_string()));
1516 }
1517
1518 #[test]
1520 fn plan_members_include_snapshot_and_verification_metadata() {
1521 let manifest = valid_manifest(IdentityMode::Relocatable);
1522
1523 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1524 let root = plan
1525 .ordered_members()
1526 .into_iter()
1527 .find(|member| member.source_canister == ROOT)
1528 .expect("root member should be planned");
1529
1530 assert_eq!(root.identity_mode, IdentityMode::Fixed);
1531 assert_eq!(root.verification_class, "basic");
1532 assert_eq!(root.verification_checks[0].kind, "call");
1533 assert_eq!(root.source_snapshot.snapshot_id, "snap-root");
1534 assert_eq!(root.source_snapshot.artifact_path, "artifacts/root");
1535 }
1536
1537 #[test]
1539 fn plan_includes_mapping_summary() {
1540 let manifest = valid_manifest(IdentityMode::Relocatable);
1541 let in_place = RestorePlanner::plan(&manifest, None).expect("plan should build");
1542
1543 assert!(!in_place.identity_summary.mapping_supplied);
1544 assert!(!in_place.identity_summary.all_sources_mapped);
1545 assert_eq!(in_place.identity_summary.mapped_members, 0);
1546
1547 let mapping = RestoreMapping {
1548 members: vec![
1549 RestoreMappingEntry {
1550 source_canister: ROOT.to_string(),
1551 target_canister: ROOT.to_string(),
1552 },
1553 RestoreMappingEntry {
1554 source_canister: CHILD.to_string(),
1555 target_canister: TARGET.to_string(),
1556 },
1557 ],
1558 };
1559 let mapped = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
1560
1561 assert!(mapped.identity_summary.mapping_supplied);
1562 assert!(mapped.identity_summary.all_sources_mapped);
1563 assert_eq!(mapped.identity_summary.mapped_members, 2);
1564 assert_eq!(mapped.identity_summary.remapped_members, 1);
1565 }
1566
1567 #[test]
1569 fn plan_includes_snapshot_summary() {
1570 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1571 manifest.fleet.members[1].source_snapshot.module_hash = None;
1572 manifest.fleet.members[1].source_snapshot.wasm_hash = None;
1573 manifest.fleet.members[1].source_snapshot.checksum = None;
1574
1575 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1576
1577 assert!(!plan.snapshot_summary.all_members_have_module_hash);
1578 assert!(!plan.snapshot_summary.all_members_have_wasm_hash);
1579 assert!(plan.snapshot_summary.all_members_have_code_version);
1580 assert!(!plan.snapshot_summary.all_members_have_checksum);
1581 assert_eq!(plan.snapshot_summary.members_with_module_hash, 1);
1582 assert_eq!(plan.snapshot_summary.members_with_wasm_hash, 1);
1583 assert_eq!(plan.snapshot_summary.members_with_code_version, 2);
1584 assert_eq!(plan.snapshot_summary.members_with_checksum, 1);
1585 assert!(!plan.readiness_summary.ready);
1586 assert_eq!(
1587 plan.readiness_summary.reasons,
1588 [
1589 "missing-module-hash",
1590 "missing-wasm-hash",
1591 "missing-snapshot-checksum"
1592 ]
1593 );
1594 }
1595
1596 #[test]
1598 fn plan_includes_verification_summary() {
1599 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1600 manifest.verification.fleet_checks.push(VerificationCheck {
1601 kind: "fleet-ready".to_string(),
1602 method: None,
1603 roles: Vec::new(),
1604 });
1605 manifest
1606 .verification
1607 .member_checks
1608 .push(MemberVerificationChecks {
1609 role: "app".to_string(),
1610 checks: vec![VerificationCheck {
1611 kind: "app-ready".to_string(),
1612 method: Some("ready".to_string()),
1613 roles: Vec::new(),
1614 }],
1615 });
1616
1617 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1618
1619 assert!(plan.verification_summary.verification_required);
1620 assert!(plan.verification_summary.all_members_have_checks);
1621 assert_eq!(plan.verification_summary.fleet_checks, 1);
1622 assert_eq!(plan.verification_summary.member_check_groups, 1);
1623 assert_eq!(plan.verification_summary.member_checks, 3);
1624 assert_eq!(plan.verification_summary.members_with_checks, 2);
1625 assert_eq!(plan.verification_summary.total_checks, 4);
1626 }
1627
1628 #[test]
1630 fn plan_includes_operation_summary() {
1631 let manifest = valid_manifest(IdentityMode::Relocatable);
1632
1633 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1634
1635 assert_eq!(plan.operation_summary.planned_snapshot_loads, 2);
1636 assert_eq!(plan.operation_summary.planned_code_reinstalls, 2);
1637 assert_eq!(plan.operation_summary.planned_verification_checks, 2);
1638 assert_eq!(plan.operation_summary.planned_phases, 1);
1639 }
1640
1641 #[test]
1643 fn restore_status_starts_all_members_as_planned() {
1644 let manifest = valid_manifest(IdentityMode::Relocatable);
1645
1646 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1647 let status = RestoreStatus::from_plan(&plan);
1648
1649 assert_eq!(status.status_version, 1);
1650 assert_eq!(status.backup_id.as_str(), plan.backup_id.as_str());
1651 assert_eq!(
1652 status.source_environment.as_str(),
1653 plan.source_environment.as_str()
1654 );
1655 assert_eq!(
1656 status.source_root_canister.as_str(),
1657 plan.source_root_canister.as_str()
1658 );
1659 assert_eq!(status.topology_hash.as_str(), plan.topology_hash.as_str());
1660 assert!(status.ready);
1661 assert!(status.readiness_reasons.is_empty());
1662 assert!(status.verification_required);
1663 assert_eq!(status.member_count, 2);
1664 assert_eq!(status.phase_count, 1);
1665 assert_eq!(status.planned_snapshot_loads, 2);
1666 assert_eq!(status.planned_code_reinstalls, 2);
1667 assert_eq!(status.planned_verification_checks, 2);
1668 assert_eq!(status.phases.len(), 1);
1669 assert_eq!(status.phases[0].restore_group, 1);
1670 assert_eq!(status.phases[0].members.len(), 2);
1671 assert_eq!(
1672 status.phases[0].members[0].state,
1673 RestoreMemberState::Planned
1674 );
1675 assert_eq!(status.phases[0].members[0].source_canister, ROOT);
1676 assert_eq!(status.phases[0].members[0].target_canister, ROOT);
1677 assert_eq!(status.phases[0].members[0].snapshot_id, "snap-root");
1678 assert_eq!(status.phases[0].members[0].artifact_path, "artifacts/root");
1679 assert_eq!(
1680 status.phases[0].members[1].state,
1681 RestoreMemberState::Planned
1682 );
1683 assert_eq!(status.phases[0].members[1].source_canister, CHILD);
1684 }
1685
1686 #[test]
1688 fn apply_dry_run_renders_ordered_member_operations() {
1689 let manifest = valid_manifest(IdentityMode::Relocatable);
1690
1691 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1692 let status = RestoreStatus::from_plan(&plan);
1693 let dry_run =
1694 RestoreApplyDryRun::try_from_plan(&plan, Some(&status)).expect("dry-run should build");
1695
1696 assert_eq!(dry_run.dry_run_version, 1);
1697 assert_eq!(dry_run.backup_id.as_str(), "fbk_test_001");
1698 assert!(dry_run.ready);
1699 assert!(dry_run.status_supplied);
1700 assert_eq!(dry_run.member_count, 2);
1701 assert_eq!(dry_run.phase_count, 1);
1702 assert_eq!(dry_run.planned_snapshot_loads, 2);
1703 assert_eq!(dry_run.planned_code_reinstalls, 2);
1704 assert_eq!(dry_run.planned_verification_checks, 2);
1705 assert_eq!(dry_run.rendered_operations, 8);
1706 assert_eq!(dry_run.phases.len(), 1);
1707
1708 let operations = &dry_run.phases[0].operations;
1709 assert_eq!(operations[0].sequence, 0);
1710 assert_eq!(
1711 operations[0].operation,
1712 RestoreApplyOperationKind::UploadSnapshot
1713 );
1714 assert_eq!(operations[0].source_canister, ROOT);
1715 assert_eq!(operations[0].target_canister, ROOT);
1716 assert_eq!(operations[0].snapshot_id, Some("snap-root".to_string()));
1717 assert_eq!(
1718 operations[0].artifact_path,
1719 Some("artifacts/root".to_string())
1720 );
1721 assert_eq!(
1722 operations[1].operation,
1723 RestoreApplyOperationKind::LoadSnapshot
1724 );
1725 assert_eq!(
1726 operations[2].operation,
1727 RestoreApplyOperationKind::ReinstallCode
1728 );
1729 assert_eq!(
1730 operations[3].operation,
1731 RestoreApplyOperationKind::VerifyMember
1732 );
1733 assert_eq!(operations[3].verification_kind, Some("call".to_string()));
1734 assert_eq!(
1735 operations[3].verification_method,
1736 Some("canic_ready".to_string())
1737 );
1738 assert_eq!(operations[4].source_canister, CHILD);
1739 assert_eq!(
1740 operations[7].operation,
1741 RestoreApplyOperationKind::VerifyMember
1742 );
1743 }
1744
1745 #[test]
1747 fn apply_dry_run_sequences_operations_across_phases() {
1748 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1749 manifest.fleet.members[0].restore_group = 2;
1750
1751 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1752 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
1753
1754 assert_eq!(dry_run.phases.len(), 2);
1755 assert_eq!(dry_run.rendered_operations, 8);
1756 assert_eq!(dry_run.phases[0].operations[0].sequence, 0);
1757 assert_eq!(dry_run.phases[0].operations[3].sequence, 3);
1758 assert_eq!(dry_run.phases[1].operations[0].sequence, 4);
1759 assert_eq!(dry_run.phases[1].operations[3].sequence, 7);
1760 }
1761
1762 #[test]
1764 fn apply_dry_run_validates_artifacts_under_backup_root() {
1765 let root = temp_dir("canic-restore-apply-artifacts-ok");
1766 fs::create_dir_all(&root).expect("create temp root");
1767 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1768 set_member_artifact(
1769 &mut manifest,
1770 CHILD,
1771 &root,
1772 "artifacts/child",
1773 b"child-snapshot",
1774 );
1775 set_member_artifact(
1776 &mut manifest,
1777 ROOT,
1778 &root,
1779 "artifacts/root",
1780 b"root-snapshot",
1781 );
1782
1783 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1784 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
1785 .expect("dry-run should validate artifacts");
1786
1787 let validation = dry_run
1788 .artifact_validation
1789 .expect("artifact validation should be present");
1790 assert_eq!(validation.checked_members, 2);
1791 assert!(validation.artifacts_present);
1792 assert!(validation.checksums_verified);
1793 assert_eq!(validation.members_with_expected_checksums, 2);
1794 assert_eq!(validation.checks[0].source_canister, ROOT);
1795 assert!(validation.checks[0].checksum_verified);
1796
1797 fs::remove_dir_all(root).expect("remove temp root");
1798 }
1799
1800 #[test]
1802 fn apply_dry_run_rejects_missing_artifacts() {
1803 let root = temp_dir("canic-restore-apply-artifacts-missing");
1804 fs::create_dir_all(&root).expect("create temp root");
1805 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1806 manifest.fleet.members[0].source_snapshot.artifact_path = "missing-child".to_string();
1807
1808 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1809 let err = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
1810 .expect_err("missing artifact should fail");
1811
1812 fs::remove_dir_all(root).expect("remove temp root");
1813 assert!(matches!(
1814 err,
1815 RestoreApplyDryRunError::ArtifactMissing { .. }
1816 ));
1817 }
1818
1819 #[test]
1821 fn apply_dry_run_rejects_artifact_path_traversal() {
1822 let root = temp_dir("canic-restore-apply-artifacts-traversal");
1823 fs::create_dir_all(&root).expect("create temp root");
1824 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1825 manifest.fleet.members[1].source_snapshot.artifact_path = "../outside".to_string();
1826
1827 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1828 let err = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
1829 .expect_err("path traversal should fail");
1830
1831 fs::remove_dir_all(root).expect("remove temp root");
1832 assert!(matches!(
1833 err,
1834 RestoreApplyDryRunError::ArtifactPathEscapesBackup { .. }
1835 ));
1836 }
1837
1838 #[test]
1840 fn apply_dry_run_rejects_mismatched_status() {
1841 let manifest = valid_manifest(IdentityMode::Relocatable);
1842
1843 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1844 let mut status = RestoreStatus::from_plan(&plan);
1845 status.backup_id = "other-backup".to_string();
1846
1847 let err = RestoreApplyDryRun::try_from_plan(&plan, Some(&status))
1848 .expect_err("mismatched status should fail");
1849
1850 assert!(matches!(
1851 err,
1852 RestoreApplyDryRunError::StatusPlanMismatch {
1853 field: "backup_id",
1854 ..
1855 }
1856 ));
1857 }
1858
1859 #[test]
1861 fn plan_expands_role_verification_checks_per_matching_member() {
1862 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1863 manifest.fleet.members.push(fleet_member(
1864 "app",
1865 CHILD_TWO,
1866 Some(ROOT),
1867 IdentityMode::Relocatable,
1868 1,
1869 ));
1870 manifest
1871 .verification
1872 .member_checks
1873 .push(MemberVerificationChecks {
1874 role: "app".to_string(),
1875 checks: vec![VerificationCheck {
1876 kind: "app-ready".to_string(),
1877 method: Some("ready".to_string()),
1878 roles: Vec::new(),
1879 }],
1880 });
1881
1882 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1883
1884 assert_eq!(plan.verification_summary.fleet_checks, 0);
1885 assert_eq!(plan.verification_summary.member_check_groups, 1);
1886 assert_eq!(plan.verification_summary.member_checks, 5);
1887 assert_eq!(plan.verification_summary.members_with_checks, 3);
1888 assert_eq!(plan.verification_summary.total_checks, 5);
1889 }
1890
1891 #[test]
1893 fn mapped_restore_requires_complete_mapping() {
1894 let manifest = valid_manifest(IdentityMode::Relocatable);
1895 let mapping = RestoreMapping {
1896 members: vec![RestoreMappingEntry {
1897 source_canister: ROOT.to_string(),
1898 target_canister: ROOT.to_string(),
1899 }],
1900 };
1901
1902 let err = RestorePlanner::plan(&manifest, Some(&mapping))
1903 .expect_err("incomplete mapping should fail");
1904
1905 assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
1906 }
1907
1908 #[test]
1910 fn mapped_restore_rejects_unknown_mapping_sources() {
1911 let manifest = valid_manifest(IdentityMode::Relocatable);
1912 let unknown = "rdmx6-jaaaa-aaaaa-aaadq-cai";
1913 let mapping = RestoreMapping {
1914 members: vec![
1915 RestoreMappingEntry {
1916 source_canister: ROOT.to_string(),
1917 target_canister: ROOT.to_string(),
1918 },
1919 RestoreMappingEntry {
1920 source_canister: CHILD.to_string(),
1921 target_canister: TARGET.to_string(),
1922 },
1923 RestoreMappingEntry {
1924 source_canister: unknown.to_string(),
1925 target_canister: unknown.to_string(),
1926 },
1927 ],
1928 };
1929
1930 let err = RestorePlanner::plan(&manifest, Some(&mapping))
1931 .expect_err("unknown mapping source should fail");
1932
1933 assert!(matches!(err, RestorePlanError::UnknownMappingSource(_)));
1934 }
1935
1936 #[test]
1938 fn duplicate_mapping_targets_fail_validation() {
1939 let manifest = valid_manifest(IdentityMode::Relocatable);
1940 let mapping = RestoreMapping {
1941 members: vec![
1942 RestoreMappingEntry {
1943 source_canister: ROOT.to_string(),
1944 target_canister: ROOT.to_string(),
1945 },
1946 RestoreMappingEntry {
1947 source_canister: CHILD.to_string(),
1948 target_canister: ROOT.to_string(),
1949 },
1950 ],
1951 };
1952
1953 let err = RestorePlanner::plan(&manifest, Some(&mapping))
1954 .expect_err("duplicate targets should fail");
1955
1956 assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
1957 }
1958
1959 fn set_member_artifact(
1961 manifest: &mut FleetBackupManifest,
1962 canister_id: &str,
1963 root: &Path,
1964 artifact_path: &str,
1965 bytes: &[u8],
1966 ) {
1967 let full_path = root.join(artifact_path);
1968 fs::create_dir_all(full_path.parent().expect("artifact parent")).expect("create parent");
1969 fs::write(&full_path, bytes).expect("write artifact");
1970 let checksum = ArtifactChecksum::from_bytes(bytes);
1971 let member = manifest
1972 .fleet
1973 .members
1974 .iter_mut()
1975 .find(|member| member.canister_id == canister_id)
1976 .expect("member should exist");
1977 member.source_snapshot.artifact_path = artifact_path.to_string();
1978 member.source_snapshot.checksum = Some(checksum.hash);
1979 }
1980
1981 fn temp_dir(name: &str) -> PathBuf {
1983 let nanos = SystemTime::now()
1984 .duration_since(UNIX_EPOCH)
1985 .expect("system time should be after epoch")
1986 .as_nanos();
1987 env::temp_dir().join(format!("{name}-{nanos}"))
1988 }
1989}