1use crate::manifest::{FleetBackupManifest, FleetMember, IdentityMode, ManifestValidationError};
2use candid::Principal;
3use serde::{Deserialize, Serialize};
4use std::{
5 collections::{BTreeMap, BTreeSet},
6 str::FromStr,
7};
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 phases: Vec<RestorePhase>,
46}
47
48impl RestorePlan {
49 #[must_use]
51 pub fn ordered_members(&self) -> Vec<&RestorePlanMember> {
52 self.phases
53 .iter()
54 .flat_map(|phase| phase.members.iter())
55 .collect()
56 }
57}
58
59#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
64pub struct RestorePhase {
65 pub restore_group: u16,
66 pub members: Vec<RestorePlanMember>,
67}
68
69#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
74pub struct RestorePlanMember {
75 pub source_canister: String,
76 pub target_canister: String,
77 pub role: String,
78 pub parent_source_canister: Option<String>,
79 pub restore_group: u16,
80}
81
82pub struct RestorePlanner;
87
88impl RestorePlanner {
89 pub fn plan(
91 manifest: &FleetBackupManifest,
92 mapping: Option<&RestoreMapping>,
93 ) -> Result<RestorePlan, RestorePlanError> {
94 manifest.validate()?;
95 if let Some(mapping) = mapping {
96 validate_mapping(mapping)?;
97 }
98
99 let members = resolve_members(manifest, mapping)?;
100 let phases = group_and_order_members(members)?;
101
102 Ok(RestorePlan { phases })
103 }
104}
105
106#[derive(Debug, ThisError)]
111pub enum RestorePlanError {
112 #[error(transparent)]
113 InvalidManifest(#[from] ManifestValidationError),
114
115 #[error("field {field} must be a valid principal: {value}")]
116 InvalidPrincipal { field: &'static str, value: String },
117
118 #[error("mapping contains duplicate source canister {0}")]
119 DuplicateMappingSource(String),
120
121 #[error("mapping contains duplicate target canister {0}")]
122 DuplicateMappingTarget(String),
123
124 #[error("mapping is missing source canister {0}")]
125 MissingMappingSource(String),
126
127 #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
128 FixedIdentityRemap {
129 source_canister: String,
130 target_canister: String,
131 },
132
133 #[error("restore plan contains duplicate target canister {0}")]
134 DuplicatePlanTarget(String),
135
136 #[error("restore group {0} contains a parent cycle or unresolved dependency")]
137 RestoreOrderCycle(u16),
138}
139
140fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
142 let mut sources = BTreeSet::new();
143 let mut targets = BTreeSet::new();
144
145 for entry in &mapping.members {
146 validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
147 validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
148
149 if !sources.insert(entry.source_canister.clone()) {
150 return Err(RestorePlanError::DuplicateMappingSource(
151 entry.source_canister.clone(),
152 ));
153 }
154
155 if !targets.insert(entry.target_canister.clone()) {
156 return Err(RestorePlanError::DuplicateMappingTarget(
157 entry.target_canister.clone(),
158 ));
159 }
160 }
161
162 Ok(())
163}
164
165fn resolve_members(
167 manifest: &FleetBackupManifest,
168 mapping: Option<&RestoreMapping>,
169) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
170 let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
171 let mut targets = BTreeSet::new();
172
173 for member in &manifest.fleet.members {
174 let target = resolve_target(member, mapping)?;
175 if !targets.insert(target.clone()) {
176 return Err(RestorePlanError::DuplicatePlanTarget(target));
177 }
178
179 plan_members.push(RestorePlanMember {
180 source_canister: member.canister_id.clone(),
181 target_canister: target,
182 role: member.role.clone(),
183 parent_source_canister: member.parent_canister_id.clone(),
184 restore_group: member.restore_group,
185 });
186 }
187
188 Ok(plan_members)
189}
190
191fn resolve_target(
193 member: &FleetMember,
194 mapping: Option<&RestoreMapping>,
195) -> Result<String, RestorePlanError> {
196 let target = match mapping {
197 Some(mapping) => mapping
198 .target_for(&member.canister_id)
199 .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
200 .to_string(),
201 None => member.canister_id.clone(),
202 };
203
204 if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
205 return Err(RestorePlanError::FixedIdentityRemap {
206 source_canister: member.canister_id.clone(),
207 target_canister: target,
208 });
209 }
210
211 Ok(target)
212}
213
214fn group_and_order_members(
216 members: Vec<RestorePlanMember>,
217) -> Result<Vec<RestorePhase>, RestorePlanError> {
218 let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
219 for member in members {
220 groups.entry(member.restore_group).or_default().push(member);
221 }
222
223 groups
224 .into_iter()
225 .map(|(restore_group, members)| {
226 let members = order_group(restore_group, members)?;
227 Ok(RestorePhase {
228 restore_group,
229 members,
230 })
231 })
232 .collect()
233}
234
235fn order_group(
237 restore_group: u16,
238 members: Vec<RestorePlanMember>,
239) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
240 let mut remaining = members;
241 let group_sources = remaining
242 .iter()
243 .map(|member| member.source_canister.clone())
244 .collect::<BTreeSet<_>>();
245 let mut emitted = BTreeSet::new();
246 let mut ordered = Vec::with_capacity(remaining.len());
247
248 while !remaining.is_empty() {
249 let Some(index) = remaining
250 .iter()
251 .position(|member| parent_satisfied(member, &group_sources, &emitted))
252 else {
253 return Err(RestorePlanError::RestoreOrderCycle(restore_group));
254 };
255
256 let member = remaining.remove(index);
257 emitted.insert(member.source_canister.clone());
258 ordered.push(member);
259 }
260
261 Ok(ordered)
262}
263
264fn parent_satisfied(
266 member: &RestorePlanMember,
267 group_sources: &BTreeSet<String>,
268 emitted: &BTreeSet<String>,
269) -> bool {
270 match &member.parent_source_canister {
271 Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
272 _ => true,
273 }
274}
275
276fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
278 Principal::from_str(value)
279 .map(|_| ())
280 .map_err(|_| RestorePlanError::InvalidPrincipal {
281 field,
282 value: value.to_string(),
283 })
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use crate::manifest::{
290 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetSection,
291 SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck, VerificationPlan,
292 };
293
294 const ROOT: &str = "aaaaa-aa";
295 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
296 const TARGET: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
297 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
298
299 fn valid_manifest(identity_mode: IdentityMode) -> FleetBackupManifest {
301 FleetBackupManifest {
302 manifest_version: 1,
303 backup_id: "fbk_test_001".to_string(),
304 created_at: "2026-04-10T12:00:00Z".to_string(),
305 tool: ToolMetadata {
306 name: "canic".to_string(),
307 version: "v1".to_string(),
308 },
309 source: SourceMetadata {
310 environment: "local".to_string(),
311 root_canister: ROOT.to_string(),
312 },
313 consistency: ConsistencySection {
314 mode: ConsistencyMode::CrashConsistent,
315 backup_units: vec![BackupUnit {
316 unit_id: "whole-fleet".to_string(),
317 kind: BackupUnitKind::WholeFleet,
318 roles: vec!["root".to_string(), "app".to_string()],
319 consistency_reason: None,
320 dependency_closure: Vec::new(),
321 topology_validation: "subtree-closed".to_string(),
322 quiescence_strategy: None,
323 }],
324 },
325 fleet: FleetSection {
326 topology_hash_algorithm: "sha256".to_string(),
327 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
328 discovery_topology_hash: HASH.to_string(),
329 pre_snapshot_topology_hash: HASH.to_string(),
330 topology_hash: HASH.to_string(),
331 members: vec![
332 fleet_member("app", CHILD, Some(ROOT), identity_mode, 1),
333 fleet_member("root", ROOT, None, IdentityMode::Fixed, 1),
334 ],
335 },
336 verification: VerificationPlan {
337 fleet_checks: Vec::new(),
338 member_checks: Vec::new(),
339 },
340 }
341 }
342
343 fn fleet_member(
345 role: &str,
346 canister_id: &str,
347 parent_canister_id: Option<&str>,
348 identity_mode: IdentityMode,
349 restore_group: u16,
350 ) -> FleetMember {
351 FleetMember {
352 role: role.to_string(),
353 canister_id: canister_id.to_string(),
354 parent_canister_id: parent_canister_id.map(str::to_string),
355 subnet_canister_id: None,
356 controller_hint: Some(ROOT.to_string()),
357 identity_mode,
358 restore_group,
359 verification_class: "basic".to_string(),
360 verification_checks: vec![VerificationCheck {
361 kind: "call".to_string(),
362 method: Some("canic_ready".to_string()),
363 roles: Vec::new(),
364 }],
365 source_snapshot: SourceSnapshot {
366 snapshot_id: format!("snap-{role}"),
367 module_hash: Some(HASH.to_string()),
368 wasm_hash: Some(HASH.to_string()),
369 code_version: Some("v0.30.0".to_string()),
370 artifact_path: format!("artifacts/{role}"),
371 checksum_algorithm: "sha256".to_string(),
372 },
373 }
374 }
375
376 #[test]
378 fn in_place_plan_orders_parent_before_child() {
379 let manifest = valid_manifest(IdentityMode::Relocatable);
380
381 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
382 let ordered = plan.ordered_members();
383
384 assert_eq!(ordered[0].source_canister, ROOT);
385 assert_eq!(ordered[1].source_canister, CHILD);
386 }
387
388 #[test]
390 fn fixed_identity_member_cannot_be_remapped() {
391 let manifest = valid_manifest(IdentityMode::Fixed);
392 let mapping = RestoreMapping {
393 members: vec![
394 RestoreMappingEntry {
395 source_canister: ROOT.to_string(),
396 target_canister: ROOT.to_string(),
397 },
398 RestoreMappingEntry {
399 source_canister: CHILD.to_string(),
400 target_canister: TARGET.to_string(),
401 },
402 ],
403 };
404
405 let err = RestorePlanner::plan(&manifest, Some(&mapping))
406 .expect_err("fixed member remap should fail");
407
408 assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
409 }
410
411 #[test]
413 fn relocatable_member_can_be_mapped() {
414 let manifest = valid_manifest(IdentityMode::Relocatable);
415 let mapping = RestoreMapping {
416 members: vec![
417 RestoreMappingEntry {
418 source_canister: ROOT.to_string(),
419 target_canister: ROOT.to_string(),
420 },
421 RestoreMappingEntry {
422 source_canister: CHILD.to_string(),
423 target_canister: TARGET.to_string(),
424 },
425 ],
426 };
427
428 let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
429 let child = plan
430 .ordered_members()
431 .into_iter()
432 .find(|member| member.source_canister == CHILD)
433 .expect("child member should be planned");
434
435 assert_eq!(child.target_canister, TARGET);
436 }
437
438 #[test]
440 fn mapped_restore_requires_complete_mapping() {
441 let manifest = valid_manifest(IdentityMode::Relocatable);
442 let mapping = RestoreMapping {
443 members: vec![RestoreMappingEntry {
444 source_canister: ROOT.to_string(),
445 target_canister: ROOT.to_string(),
446 }],
447 };
448
449 let err = RestorePlanner::plan(&manifest, Some(&mapping))
450 .expect_err("incomplete mapping should fail");
451
452 assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
453 }
454
455 #[test]
457 fn duplicate_mapping_targets_fail_validation() {
458 let manifest = valid_manifest(IdentityMode::Relocatable);
459 let mapping = RestoreMapping {
460 members: vec![
461 RestoreMappingEntry {
462 source_canister: ROOT.to_string(),
463 target_canister: ROOT.to_string(),
464 },
465 RestoreMappingEntry {
466 source_canister: CHILD.to_string(),
467 target_canister: ROOT.to_string(),
468 },
469 ],
470 };
471
472 let err = RestorePlanner::plan(&manifest, Some(&mapping))
473 .expect_err("duplicate targets should fail");
474
475 assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
476 }
477}