1use crate::manifest::{
2 FleetBackupManifest, FleetMember, IdentityMode, ManifestValidationError, SourceSnapshot,
3 VerificationCheck,
4};
5use candid::Principal;
6use serde::{Deserialize, Serialize};
7use std::{
8 collections::{BTreeMap, BTreeSet},
9 str::FromStr,
10};
11use thiserror::Error as ThisError;
12
13#[derive(Clone, Debug, Default, Deserialize, Serialize)]
18pub struct RestoreMapping {
19 pub members: Vec<RestoreMappingEntry>,
20}
21
22impl RestoreMapping {
23 fn target_for(&self, source_canister: &str) -> Option<&str> {
25 self.members
26 .iter()
27 .find(|entry| entry.source_canister == source_canister)
28 .map(|entry| entry.target_canister.as_str())
29 }
30}
31
32#[derive(Clone, Debug, Deserialize, Serialize)]
37pub struct RestoreMappingEntry {
38 pub source_canister: String,
39 pub target_canister: String,
40}
41
42#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
47pub struct RestorePlan {
48 pub backup_id: String,
49 pub source_environment: String,
50 pub source_root_canister: String,
51 pub topology_hash: String,
52 pub member_count: usize,
53 pub phases: Vec<RestorePhase>,
54}
55
56impl RestorePlan {
57 #[must_use]
59 pub fn ordered_members(&self) -> Vec<&RestorePlanMember> {
60 self.phases
61 .iter()
62 .flat_map(|phase| phase.members.iter())
63 .collect()
64 }
65}
66
67#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
72pub struct RestorePhase {
73 pub restore_group: u16,
74 pub members: Vec<RestorePlanMember>,
75}
76
77#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
82pub struct RestorePlanMember {
83 pub source_canister: String,
84 pub target_canister: String,
85 pub role: String,
86 pub parent_source_canister: Option<String>,
87 pub parent_target_canister: Option<String>,
88 pub restore_group: u16,
89 pub identity_mode: IdentityMode,
90 pub verification_class: String,
91 pub verification_checks: Vec<VerificationCheck>,
92 pub source_snapshot: SourceSnapshot,
93}
94
95pub struct RestorePlanner;
100
101impl RestorePlanner {
102 pub fn plan(
104 manifest: &FleetBackupManifest,
105 mapping: Option<&RestoreMapping>,
106 ) -> Result<RestorePlan, RestorePlanError> {
107 manifest.validate()?;
108 if let Some(mapping) = mapping {
109 validate_mapping(mapping)?;
110 validate_mapping_sources(manifest, mapping)?;
111 }
112
113 let members = resolve_members(manifest, mapping)?;
114 let phases = group_and_order_members(members)?;
115
116 Ok(RestorePlan {
117 backup_id: manifest.backup_id.clone(),
118 source_environment: manifest.source.environment.clone(),
119 source_root_canister: manifest.source.root_canister.clone(),
120 topology_hash: manifest.fleet.topology_hash.clone(),
121 member_count: manifest.fleet.members.len(),
122 phases,
123 })
124 }
125}
126
127#[derive(Debug, ThisError)]
132pub enum RestorePlanError {
133 #[error(transparent)]
134 InvalidManifest(#[from] ManifestValidationError),
135
136 #[error("field {field} must be a valid principal: {value}")]
137 InvalidPrincipal { field: &'static str, value: String },
138
139 #[error("mapping contains duplicate source canister {0}")]
140 DuplicateMappingSource(String),
141
142 #[error("mapping contains duplicate target canister {0}")]
143 DuplicateMappingTarget(String),
144
145 #[error("mapping references unknown source canister {0}")]
146 UnknownMappingSource(String),
147
148 #[error("mapping is missing source canister {0}")]
149 MissingMappingSource(String),
150
151 #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
152 FixedIdentityRemap {
153 source_canister: String,
154 target_canister: String,
155 },
156
157 #[error("restore plan contains duplicate target canister {0}")]
158 DuplicatePlanTarget(String),
159
160 #[error("restore group {0} contains a parent cycle or unresolved dependency")]
161 RestoreOrderCycle(u16),
162}
163
164fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
166 let mut sources = BTreeSet::new();
167 let mut targets = BTreeSet::new();
168
169 for entry in &mapping.members {
170 validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
171 validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
172
173 if !sources.insert(entry.source_canister.clone()) {
174 return Err(RestorePlanError::DuplicateMappingSource(
175 entry.source_canister.clone(),
176 ));
177 }
178
179 if !targets.insert(entry.target_canister.clone()) {
180 return Err(RestorePlanError::DuplicateMappingTarget(
181 entry.target_canister.clone(),
182 ));
183 }
184 }
185
186 Ok(())
187}
188
189fn validate_mapping_sources(
191 manifest: &FleetBackupManifest,
192 mapping: &RestoreMapping,
193) -> Result<(), RestorePlanError> {
194 let sources = manifest
195 .fleet
196 .members
197 .iter()
198 .map(|member| member.canister_id.as_str())
199 .collect::<BTreeSet<_>>();
200
201 for entry in &mapping.members {
202 if !sources.contains(entry.source_canister.as_str()) {
203 return Err(RestorePlanError::UnknownMappingSource(
204 entry.source_canister.clone(),
205 ));
206 }
207 }
208
209 Ok(())
210}
211
212fn resolve_members(
214 manifest: &FleetBackupManifest,
215 mapping: Option<&RestoreMapping>,
216) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
217 let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
218 let mut targets = BTreeSet::new();
219 let mut source_to_target = BTreeMap::new();
220
221 for member in &manifest.fleet.members {
222 let target = resolve_target(member, mapping)?;
223 if !targets.insert(target.clone()) {
224 return Err(RestorePlanError::DuplicatePlanTarget(target));
225 }
226
227 source_to_target.insert(member.canister_id.clone(), target.clone());
228 plan_members.push(RestorePlanMember {
229 source_canister: member.canister_id.clone(),
230 target_canister: target,
231 role: member.role.clone(),
232 parent_source_canister: member.parent_canister_id.clone(),
233 parent_target_canister: None,
234 restore_group: member.restore_group,
235 identity_mode: member.identity_mode.clone(),
236 verification_class: member.verification_class.clone(),
237 verification_checks: member.verification_checks.clone(),
238 source_snapshot: member.source_snapshot.clone(),
239 });
240 }
241
242 for member in &mut plan_members {
243 member.parent_target_canister = member
244 .parent_source_canister
245 .as_ref()
246 .and_then(|parent| source_to_target.get(parent))
247 .cloned();
248 }
249
250 Ok(plan_members)
251}
252
253fn resolve_target(
255 member: &FleetMember,
256 mapping: Option<&RestoreMapping>,
257) -> Result<String, RestorePlanError> {
258 let target = match mapping {
259 Some(mapping) => mapping
260 .target_for(&member.canister_id)
261 .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
262 .to_string(),
263 None => member.canister_id.clone(),
264 };
265
266 if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
267 return Err(RestorePlanError::FixedIdentityRemap {
268 source_canister: member.canister_id.clone(),
269 target_canister: target,
270 });
271 }
272
273 Ok(target)
274}
275
276fn group_and_order_members(
278 members: Vec<RestorePlanMember>,
279) -> Result<Vec<RestorePhase>, RestorePlanError> {
280 let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
281 for member in members {
282 groups.entry(member.restore_group).or_default().push(member);
283 }
284
285 groups
286 .into_iter()
287 .map(|(restore_group, members)| {
288 let members = order_group(restore_group, members)?;
289 Ok(RestorePhase {
290 restore_group,
291 members,
292 })
293 })
294 .collect()
295}
296
297fn order_group(
299 restore_group: u16,
300 members: Vec<RestorePlanMember>,
301) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
302 let mut remaining = members;
303 let group_sources = remaining
304 .iter()
305 .map(|member| member.source_canister.clone())
306 .collect::<BTreeSet<_>>();
307 let mut emitted = BTreeSet::new();
308 let mut ordered = Vec::with_capacity(remaining.len());
309
310 while !remaining.is_empty() {
311 let Some(index) = remaining
312 .iter()
313 .position(|member| parent_satisfied(member, &group_sources, &emitted))
314 else {
315 return Err(RestorePlanError::RestoreOrderCycle(restore_group));
316 };
317
318 let member = remaining.remove(index);
319 emitted.insert(member.source_canister.clone());
320 ordered.push(member);
321 }
322
323 Ok(ordered)
324}
325
326fn parent_satisfied(
328 member: &RestorePlanMember,
329 group_sources: &BTreeSet<String>,
330 emitted: &BTreeSet<String>,
331) -> bool {
332 match &member.parent_source_canister {
333 Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
334 _ => true,
335 }
336}
337
338fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
340 Principal::from_str(value)
341 .map(|_| ())
342 .map_err(|_| RestorePlanError::InvalidPrincipal {
343 field,
344 value: value.to_string(),
345 })
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::manifest::{
352 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetSection,
353 SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck, VerificationPlan,
354 };
355
356 const ROOT: &str = "aaaaa-aa";
357 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
358 const TARGET: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
359 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
360
361 fn valid_manifest(identity_mode: IdentityMode) -> FleetBackupManifest {
363 FleetBackupManifest {
364 manifest_version: 1,
365 backup_id: "fbk_test_001".to_string(),
366 created_at: "2026-04-10T12:00:00Z".to_string(),
367 tool: ToolMetadata {
368 name: "canic".to_string(),
369 version: "v1".to_string(),
370 },
371 source: SourceMetadata {
372 environment: "local".to_string(),
373 root_canister: ROOT.to_string(),
374 },
375 consistency: ConsistencySection {
376 mode: ConsistencyMode::CrashConsistent,
377 backup_units: vec![BackupUnit {
378 unit_id: "whole-fleet".to_string(),
379 kind: BackupUnitKind::WholeFleet,
380 roles: vec!["root".to_string(), "app".to_string()],
381 consistency_reason: None,
382 dependency_closure: Vec::new(),
383 topology_validation: "subtree-closed".to_string(),
384 quiescence_strategy: None,
385 }],
386 },
387 fleet: FleetSection {
388 topology_hash_algorithm: "sha256".to_string(),
389 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
390 discovery_topology_hash: HASH.to_string(),
391 pre_snapshot_topology_hash: HASH.to_string(),
392 topology_hash: HASH.to_string(),
393 members: vec![
394 fleet_member("app", CHILD, Some(ROOT), identity_mode, 1),
395 fleet_member("root", ROOT, None, IdentityMode::Fixed, 1),
396 ],
397 },
398 verification: VerificationPlan {
399 fleet_checks: Vec::new(),
400 member_checks: Vec::new(),
401 },
402 }
403 }
404
405 fn fleet_member(
407 role: &str,
408 canister_id: &str,
409 parent_canister_id: Option<&str>,
410 identity_mode: IdentityMode,
411 restore_group: u16,
412 ) -> FleetMember {
413 FleetMember {
414 role: role.to_string(),
415 canister_id: canister_id.to_string(),
416 parent_canister_id: parent_canister_id.map(str::to_string),
417 subnet_canister_id: None,
418 controller_hint: Some(ROOT.to_string()),
419 identity_mode,
420 restore_group,
421 verification_class: "basic".to_string(),
422 verification_checks: vec![VerificationCheck {
423 kind: "call".to_string(),
424 method: Some("canic_ready".to_string()),
425 roles: Vec::new(),
426 }],
427 source_snapshot: SourceSnapshot {
428 snapshot_id: format!("snap-{role}"),
429 module_hash: Some(HASH.to_string()),
430 wasm_hash: Some(HASH.to_string()),
431 code_version: Some("v0.30.0".to_string()),
432 artifact_path: format!("artifacts/{role}"),
433 checksum_algorithm: "sha256".to_string(),
434 checksum: Some(HASH.to_string()),
435 },
436 }
437 }
438
439 #[test]
441 fn in_place_plan_orders_parent_before_child() {
442 let manifest = valid_manifest(IdentityMode::Relocatable);
443
444 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
445 let ordered = plan.ordered_members();
446
447 assert_eq!(plan.backup_id, "fbk_test_001");
448 assert_eq!(plan.source_environment, "local");
449 assert_eq!(plan.source_root_canister, ROOT);
450 assert_eq!(plan.topology_hash, HASH);
451 assert_eq!(plan.member_count, 2);
452 assert_eq!(ordered[0].source_canister, ROOT);
453 assert_eq!(ordered[1].source_canister, CHILD);
454 }
455
456 #[test]
458 fn fixed_identity_member_cannot_be_remapped() {
459 let manifest = valid_manifest(IdentityMode::Fixed);
460 let mapping = RestoreMapping {
461 members: vec![
462 RestoreMappingEntry {
463 source_canister: ROOT.to_string(),
464 target_canister: ROOT.to_string(),
465 },
466 RestoreMappingEntry {
467 source_canister: CHILD.to_string(),
468 target_canister: TARGET.to_string(),
469 },
470 ],
471 };
472
473 let err = RestorePlanner::plan(&manifest, Some(&mapping))
474 .expect_err("fixed member remap should fail");
475
476 assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
477 }
478
479 #[test]
481 fn relocatable_member_can_be_mapped() {
482 let manifest = valid_manifest(IdentityMode::Relocatable);
483 let mapping = RestoreMapping {
484 members: vec![
485 RestoreMappingEntry {
486 source_canister: ROOT.to_string(),
487 target_canister: ROOT.to_string(),
488 },
489 RestoreMappingEntry {
490 source_canister: CHILD.to_string(),
491 target_canister: TARGET.to_string(),
492 },
493 ],
494 };
495
496 let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
497 let child = plan
498 .ordered_members()
499 .into_iter()
500 .find(|member| member.source_canister == CHILD)
501 .expect("child member should be planned");
502
503 assert_eq!(child.target_canister, TARGET);
504 assert_eq!(child.parent_target_canister, Some(ROOT.to_string()));
505 }
506
507 #[test]
509 fn plan_members_include_snapshot_and_verification_metadata() {
510 let manifest = valid_manifest(IdentityMode::Relocatable);
511
512 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
513 let root = plan
514 .ordered_members()
515 .into_iter()
516 .find(|member| member.source_canister == ROOT)
517 .expect("root member should be planned");
518
519 assert_eq!(root.identity_mode, IdentityMode::Fixed);
520 assert_eq!(root.verification_class, "basic");
521 assert_eq!(root.verification_checks[0].kind, "call");
522 assert_eq!(root.source_snapshot.snapshot_id, "snap-root");
523 assert_eq!(root.source_snapshot.artifact_path, "artifacts/root");
524 }
525
526 #[test]
528 fn mapped_restore_requires_complete_mapping() {
529 let manifest = valid_manifest(IdentityMode::Relocatable);
530 let mapping = RestoreMapping {
531 members: vec![RestoreMappingEntry {
532 source_canister: ROOT.to_string(),
533 target_canister: ROOT.to_string(),
534 }],
535 };
536
537 let err = RestorePlanner::plan(&manifest, Some(&mapping))
538 .expect_err("incomplete mapping should fail");
539
540 assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
541 }
542
543 #[test]
545 fn mapped_restore_rejects_unknown_mapping_sources() {
546 let manifest = valid_manifest(IdentityMode::Relocatable);
547 let unknown = "rdmx6-jaaaa-aaaaa-aaadq-cai";
548 let mapping = RestoreMapping {
549 members: vec![
550 RestoreMappingEntry {
551 source_canister: ROOT.to_string(),
552 target_canister: ROOT.to_string(),
553 },
554 RestoreMappingEntry {
555 source_canister: CHILD.to_string(),
556 target_canister: TARGET.to_string(),
557 },
558 RestoreMappingEntry {
559 source_canister: unknown.to_string(),
560 target_canister: unknown.to_string(),
561 },
562 ],
563 };
564
565 let err = RestorePlanner::plan(&manifest, Some(&mapping))
566 .expect_err("unknown mapping source should fail");
567
568 assert!(matches!(err, RestorePlanError::UnknownMappingSource(_)));
569 }
570
571 #[test]
573 fn duplicate_mapping_targets_fail_validation() {
574 let manifest = valid_manifest(IdentityMode::Relocatable);
575 let mapping = RestoreMapping {
576 members: vec![
577 RestoreMappingEntry {
578 source_canister: ROOT.to_string(),
579 target_canister: ROOT.to_string(),
580 },
581 RestoreMappingEntry {
582 source_canister: CHILD.to_string(),
583 target_canister: ROOT.to_string(),
584 },
585 ],
586 };
587
588 let err = RestorePlanner::plan(&manifest, Some(&mapping))
589 .expect_err("duplicate targets should fail");
590
591 assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
592 }
593}