1use crate::manifest::{
2 FleetBackupManifest, FleetMember, IdentityMode, ManifestValidationError, SourceSnapshot,
3 VerificationCheck, VerificationPlan,
4};
5use candid::Principal;
6use serde::{Deserialize, Serialize};
7use std::{collections::BTreeSet, str::FromStr};
8use thiserror::Error as ThisError;
9
10#[derive(Clone, Debug, Default, Deserialize, Serialize)]
15pub struct RestoreMapping {
16 pub members: Vec<RestoreMappingEntry>,
17}
18
19impl RestoreMapping {
20 fn target_for(&self, source_canister: &str) -> Option<&str> {
22 self.members
23 .iter()
24 .find(|entry| entry.source_canister == source_canister)
25 .map(|entry| entry.target_canister.as_str())
26 }
27}
28
29#[derive(Clone, Debug, Deserialize, Serialize)]
34pub struct RestoreMappingEntry {
35 pub source_canister: String,
36 pub target_canister: String,
37}
38
39#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44pub struct RestorePlan {
45 pub backup_id: String,
46 pub source_environment: String,
47 pub source_root_canister: String,
48 pub topology_hash: String,
49 pub member_count: usize,
50 pub identity_summary: RestoreIdentitySummary,
51 pub snapshot_summary: RestoreSnapshotSummary,
52 pub verification_summary: RestoreVerificationSummary,
53 pub readiness_summary: RestoreReadinessSummary,
54 pub operation_summary: RestoreOperationSummary,
55 pub ordering_summary: RestoreOrderingSummary,
56 #[serde(default)]
57 pub fleet_verification_checks: Vec<VerificationCheck>,
58 pub members: Vec<RestorePlanMember>,
59}
60
61impl RestorePlan {
62 #[must_use]
64 pub fn ordered_members(&self) -> Vec<&RestorePlanMember> {
65 self.members.iter().collect()
66 }
67}
68
69#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
74pub struct RestoreIdentitySummary {
75 pub mapping_supplied: bool,
76 pub all_sources_mapped: bool,
77 pub fixed_members: usize,
78 pub relocatable_members: usize,
79 pub in_place_members: usize,
80 pub mapped_members: usize,
81 pub remapped_members: usize,
82}
83
84#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
89#[expect(
90 clippy::struct_excessive_bools,
91 reason = "restore summaries intentionally expose machine-readable readiness flags"
92)]
93pub struct RestoreSnapshotSummary {
94 pub all_members_have_module_hash: bool,
95 pub all_members_have_wasm_hash: bool,
96 pub all_members_have_code_version: bool,
97 pub all_members_have_checksum: bool,
98 pub members_with_module_hash: usize,
99 pub members_with_wasm_hash: usize,
100 pub members_with_code_version: usize,
101 pub members_with_checksum: usize,
102}
103
104#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
109pub struct RestoreVerificationSummary {
110 pub verification_required: bool,
111 pub all_members_have_checks: bool,
112 pub fleet_checks: usize,
113 pub member_check_groups: usize,
114 pub member_checks: usize,
115 pub members_with_checks: usize,
116 pub total_checks: usize,
117}
118
119#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
124pub struct RestoreReadinessSummary {
125 pub ready: bool,
126 pub reasons: Vec<String>,
127}
128
129#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
134pub struct RestoreOperationSummary {
135 pub planned_snapshot_uploads: usize,
136 pub planned_snapshot_loads: usize,
137 pub planned_verification_checks: usize,
138 pub planned_operations: usize,
139}
140
141#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
146pub struct RestoreOrderingSummary {
147 pub ordered_members: usize,
148 pub dependency_free_members: usize,
149 pub parent_edges: usize,
150}
151
152#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
157pub struct RestorePlanMember {
158 pub source_canister: String,
159 pub target_canister: String,
160 pub role: String,
161 pub parent_source_canister: Option<String>,
162 pub parent_target_canister: Option<String>,
163 pub ordering_dependency: Option<RestoreOrderingDependency>,
164 pub member_order: usize,
165 pub identity_mode: IdentityMode,
166 pub verification_checks: Vec<VerificationCheck>,
167 pub source_snapshot: SourceSnapshot,
168}
169
170#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
175pub struct RestoreOrderingDependency {
176 pub source_canister: String,
177 pub target_canister: String,
178 pub relationship: RestoreOrderingRelationship,
179}
180
181#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
186#[serde(rename_all = "kebab-case")]
187pub enum RestoreOrderingRelationship {
188 ParentBeforeChild,
189}
190
191pub struct RestorePlanner;
196
197impl RestorePlanner {
198 pub fn plan(
200 manifest: &FleetBackupManifest,
201 mapping: Option<&RestoreMapping>,
202 ) -> Result<RestorePlan, RestorePlanError> {
203 manifest.validate()?;
204 if let Some(mapping) = mapping {
205 validate_mapping(mapping)?;
206 validate_mapping_sources(manifest, mapping)?;
207 }
208
209 let members = resolve_members(manifest, mapping)?;
210 let identity_summary = restore_identity_summary(&members, mapping.is_some());
211 let snapshot_summary = restore_snapshot_summary(&members);
212 let verification_summary = restore_verification_summary(manifest, &members);
213 let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
214 let members = order_members(members)?;
215 let ordering_summary = restore_ordering_summary(&members);
216 let operation_summary =
217 restore_operation_summary(manifest.fleet.members.len(), &verification_summary);
218
219 Ok(RestorePlan {
220 backup_id: manifest.backup_id.clone(),
221 source_environment: manifest.source.environment.clone(),
222 source_root_canister: manifest.source.root_canister.clone(),
223 topology_hash: manifest.fleet.topology_hash.clone(),
224 member_count: manifest.fleet.members.len(),
225 identity_summary,
226 snapshot_summary,
227 verification_summary,
228 readiness_summary,
229 operation_summary,
230 ordering_summary,
231 fleet_verification_checks: manifest.verification.fleet_checks.clone(),
232 members,
233 })
234 }
235}
236
237#[derive(Debug, ThisError)]
242pub enum RestorePlanError {
243 #[error(transparent)]
244 InvalidManifest(#[from] ManifestValidationError),
245
246 #[error("field {field} must be a valid principal: {value}")]
247 InvalidPrincipal { field: &'static str, value: String },
248
249 #[error("mapping contains duplicate source canister {0}")]
250 DuplicateMappingSource(String),
251
252 #[error("mapping contains duplicate target canister {0}")]
253 DuplicateMappingTarget(String),
254
255 #[error("mapping references unknown source canister {0}")]
256 UnknownMappingSource(String),
257
258 #[error("mapping is missing source canister {0}")]
259 MissingMappingSource(String),
260
261 #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
262 FixedIdentityRemap {
263 source_canister: String,
264 target_canister: String,
265 },
266
267 #[error("restore plan contains duplicate target canister {0}")]
268 DuplicatePlanTarget(String),
269
270 #[error("restore plan contains a parent cycle or unresolved dependency")]
271 RestoreOrderCycle,
272}
273
274fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
276 let mut sources = BTreeSet::new();
277 let mut targets = BTreeSet::new();
278
279 for entry in &mapping.members {
280 validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
281 validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
282
283 if !sources.insert(entry.source_canister.clone()) {
284 return Err(RestorePlanError::DuplicateMappingSource(
285 entry.source_canister.clone(),
286 ));
287 }
288
289 if !targets.insert(entry.target_canister.clone()) {
290 return Err(RestorePlanError::DuplicateMappingTarget(
291 entry.target_canister.clone(),
292 ));
293 }
294 }
295
296 Ok(())
297}
298
299fn validate_mapping_sources(
301 manifest: &FleetBackupManifest,
302 mapping: &RestoreMapping,
303) -> Result<(), RestorePlanError> {
304 let sources = manifest
305 .fleet
306 .members
307 .iter()
308 .map(|member| member.canister_id.as_str())
309 .collect::<BTreeSet<_>>();
310
311 for entry in &mapping.members {
312 if !sources.contains(entry.source_canister.as_str()) {
313 return Err(RestorePlanError::UnknownMappingSource(
314 entry.source_canister.clone(),
315 ));
316 }
317 }
318
319 Ok(())
320}
321
322fn resolve_members(
324 manifest: &FleetBackupManifest,
325 mapping: Option<&RestoreMapping>,
326) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
327 let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
328 let mut targets = BTreeSet::new();
329 let mut source_to_target = std::collections::BTreeMap::new();
330
331 for member in &manifest.fleet.members {
332 let target = resolve_target(member, mapping)?;
333 if !targets.insert(target.clone()) {
334 return Err(RestorePlanError::DuplicatePlanTarget(target));
335 }
336
337 source_to_target.insert(member.canister_id.clone(), target.clone());
338 plan_members.push(RestorePlanMember {
339 source_canister: member.canister_id.clone(),
340 target_canister: target,
341 role: member.role.clone(),
342 parent_source_canister: member.parent_canister_id.clone(),
343 parent_target_canister: None,
344 ordering_dependency: None,
345 member_order: 0,
346 identity_mode: member.identity_mode.clone(),
347 verification_checks: concrete_member_verification_checks(
348 member,
349 &manifest.verification,
350 ),
351 source_snapshot: member.source_snapshot.clone(),
352 });
353 }
354
355 for member in &mut plan_members {
356 member.parent_target_canister = member
357 .parent_source_canister
358 .as_ref()
359 .and_then(|parent| source_to_target.get(parent))
360 .cloned();
361 }
362
363 Ok(plan_members)
364}
365
366fn concrete_member_verification_checks(
368 member: &FleetMember,
369 verification: &VerificationPlan,
370) -> Vec<VerificationCheck> {
371 let mut checks = member
372 .verification_checks
373 .iter()
374 .filter(|check| verification_check_applies_to_role(check, &member.role))
375 .cloned()
376 .collect::<Vec<_>>();
377
378 for group in &verification.member_checks {
379 if group.role != member.role {
380 continue;
381 }
382
383 checks.extend(
384 group
385 .checks
386 .iter()
387 .filter(|check| verification_check_applies_to_role(check, &member.role))
388 .cloned(),
389 );
390 }
391
392 checks
393}
394
395fn verification_check_applies_to_role(check: &VerificationCheck, role: &str) -> bool {
397 check.roles.is_empty() || check.roles.iter().any(|check_role| check_role == role)
398}
399
400fn resolve_target(
402 member: &FleetMember,
403 mapping: Option<&RestoreMapping>,
404) -> Result<String, RestorePlanError> {
405 let target = match mapping {
406 Some(mapping) => mapping
407 .target_for(&member.canister_id)
408 .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
409 .to_string(),
410 None => member.canister_id.clone(),
411 };
412
413 if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
414 return Err(RestorePlanError::FixedIdentityRemap {
415 source_canister: member.canister_id.clone(),
416 target_canister: target,
417 });
418 }
419
420 Ok(target)
421}
422
423fn restore_identity_summary(
425 members: &[RestorePlanMember],
426 mapping_supplied: bool,
427) -> RestoreIdentitySummary {
428 let mut summary = RestoreIdentitySummary {
429 mapping_supplied,
430 all_sources_mapped: false,
431 fixed_members: 0,
432 relocatable_members: 0,
433 in_place_members: 0,
434 mapped_members: 0,
435 remapped_members: 0,
436 };
437
438 for member in members {
439 match member.identity_mode {
440 IdentityMode::Fixed => summary.fixed_members += 1,
441 IdentityMode::Relocatable => summary.relocatable_members += 1,
442 }
443
444 if member.source_canister == member.target_canister {
445 summary.in_place_members += 1;
446 } else {
447 summary.remapped_members += 1;
448 }
449 if mapping_supplied {
450 summary.mapped_members += 1;
451 }
452 }
453
454 summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
455
456 summary
457}
458
459fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
461 let members_with_module_hash = members
462 .iter()
463 .filter(|member| member.source_snapshot.module_hash.is_some())
464 .count();
465 let members_with_wasm_hash = members
466 .iter()
467 .filter(|member| member.source_snapshot.wasm_hash.is_some())
468 .count();
469 let members_with_code_version = members
470 .iter()
471 .filter(|member| member.source_snapshot.code_version.is_some())
472 .count();
473 let members_with_checksum = members
474 .iter()
475 .filter(|member| member.source_snapshot.checksum.is_some())
476 .count();
477
478 RestoreSnapshotSummary {
479 all_members_have_module_hash: members_with_module_hash == members.len(),
480 all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
481 all_members_have_code_version: members_with_code_version == members.len(),
482 all_members_have_checksum: members_with_checksum == members.len(),
483 members_with_module_hash,
484 members_with_wasm_hash,
485 members_with_code_version,
486 members_with_checksum,
487 }
488}
489
490fn restore_readiness_summary(
492 snapshot: &RestoreSnapshotSummary,
493 verification: &RestoreVerificationSummary,
494) -> RestoreReadinessSummary {
495 let mut reasons = Vec::new();
496
497 if !snapshot.all_members_have_checksum {
498 reasons.push("missing-snapshot-checksum".to_string());
499 }
500 if !verification.all_members_have_checks {
501 reasons.push("missing-verification-checks".to_string());
502 }
503
504 RestoreReadinessSummary {
505 ready: reasons.is_empty(),
506 reasons,
507 }
508}
509
510fn restore_verification_summary(
512 manifest: &FleetBackupManifest,
513 members: &[RestorePlanMember],
514) -> RestoreVerificationSummary {
515 let fleet_checks = manifest.verification.fleet_checks.len();
516 let member_check_groups = manifest.verification.member_checks.len();
517 let member_checks = members
518 .iter()
519 .map(|member| member.verification_checks.len())
520 .sum::<usize>();
521 let members_with_checks = members
522 .iter()
523 .filter(|member| !member.verification_checks.is_empty())
524 .count();
525
526 RestoreVerificationSummary {
527 verification_required: true,
528 all_members_have_checks: members_with_checks == members.len(),
529 fleet_checks,
530 member_check_groups,
531 member_checks,
532 members_with_checks,
533 total_checks: fleet_checks + member_checks,
534 }
535}
536
537const fn restore_operation_summary(
539 member_count: usize,
540 verification_summary: &RestoreVerificationSummary,
541) -> RestoreOperationSummary {
542 RestoreOperationSummary {
543 planned_snapshot_uploads: member_count,
544 planned_snapshot_loads: member_count,
545 planned_verification_checks: verification_summary.total_checks,
546 planned_operations: member_count + member_count + verification_summary.total_checks,
547 }
548}
549
550fn order_members(
552 members: Vec<RestorePlanMember>,
553) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
554 let mut remaining = members;
555 let group_sources = remaining
556 .iter()
557 .map(|member| member.source_canister.clone())
558 .collect::<BTreeSet<_>>();
559 let mut emitted = BTreeSet::new();
560 let mut ordered = Vec::with_capacity(remaining.len());
561
562 while !remaining.is_empty() {
563 let Some(index) = remaining
564 .iter()
565 .position(|member| parent_satisfied(member, &group_sources, &emitted))
566 else {
567 return Err(RestorePlanError::RestoreOrderCycle);
568 };
569
570 let mut member = remaining.remove(index);
571 member.member_order = ordered.len();
572 member.ordering_dependency = ordering_dependency(&member);
573 emitted.insert(member.source_canister.clone());
574 ordered.push(member);
575 }
576
577 Ok(ordered)
578}
579
580fn ordering_dependency(member: &RestorePlanMember) -> Option<RestoreOrderingDependency> {
582 let parent_source = member.parent_source_canister.as_ref()?;
583 let parent_target = member.parent_target_canister.as_ref()?;
584 let relationship = RestoreOrderingRelationship::ParentBeforeChild;
585
586 Some(RestoreOrderingDependency {
587 source_canister: parent_source.clone(),
588 target_canister: parent_target.clone(),
589 relationship,
590 })
591}
592
593fn restore_ordering_summary(members: &[RestorePlanMember]) -> RestoreOrderingSummary {
595 let mut summary = RestoreOrderingSummary {
596 ordered_members: members.len(),
597 dependency_free_members: 0,
598 parent_edges: 0,
599 };
600
601 for member in members {
602 if member.ordering_dependency.is_some() {
603 summary.parent_edges += 1;
604 } else {
605 summary.dependency_free_members += 1;
606 }
607 }
608
609 summary
610}
611
612fn parent_satisfied(
614 member: &RestorePlanMember,
615 group_sources: &BTreeSet<String>,
616 emitted: &BTreeSet<String>,
617) -> bool {
618 match &member.parent_source_canister {
619 Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
620 _ => true,
621 }
622}
623
624fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
626 Principal::from_str(value)
627 .map(|_| ())
628 .map_err(|_| RestorePlanError::InvalidPrincipal {
629 field,
630 value: value.to_string(),
631 })
632}