Skip to main content

canic_backup/restore/
mod.rs

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///
11/// RestoreMapping
12///
13
14#[derive(Clone, Debug, Default, Deserialize, Serialize)]
15pub struct RestoreMapping {
16    pub members: Vec<RestoreMappingEntry>,
17}
18
19impl RestoreMapping {
20    /// Resolve the target canister for one source member.
21    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///
30/// RestoreMappingEntry
31///
32
33#[derive(Clone, Debug, Deserialize, Serialize)]
34pub struct RestoreMappingEntry {
35    pub source_canister: String,
36    pub target_canister: String,
37}
38
39///
40/// RestorePlan
41///
42
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44pub struct RestorePlan {
45    pub phases: Vec<RestorePhase>,
46}
47
48impl RestorePlan {
49    /// Return all planned members in execution order.
50    #[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///
60/// RestorePhase
61///
62
63#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
64pub struct RestorePhase {
65    pub restore_group: u16,
66    pub members: Vec<RestorePlanMember>,
67}
68
69///
70/// RestorePlanMember
71///
72
73#[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
82///
83/// RestorePlanner
84///
85
86pub struct RestorePlanner;
87
88impl RestorePlanner {
89    /// Build a no-mutation restore plan from the manifest and optional target mapping.
90    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///
107/// RestorePlanError
108///
109
110#[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
140// Validate a user-supplied restore mapping before applying it to the manifest.
141fn 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
165// Resolve source manifest members into target restore members.
166fn 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
191// Resolve one member's target canister, enforcing identity continuity.
192fn 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
214// Group members and apply parent-before-child ordering inside each group.
215fn 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
235// Topologically order one group using manifest parent relationships.
236fn 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
264// Determine whether a member's in-group parent has already been emitted.
265fn 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
276// Validate textual principal fields used in mappings.
277fn 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    // Build one valid manifest with a parent and child in the same restore group.
300    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    // Build one manifest member for restore planning tests.
344    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    // Ensure in-place restore planning sorts parent before child.
377    #[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    // Ensure fixed identities cannot be remapped.
389    #[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    // Ensure relocatable identities may be mapped when all members are covered.
412    #[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    // Ensure mapped restores must cover every source member.
439    #[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    // Ensure duplicate target mappings fail before a plan is produced.
456    #[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}