Skip to main content

canic_backup/restore/
mod.rs

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///
14/// RestoreMapping
15///
16
17#[derive(Clone, Debug, Default, Deserialize, Serialize)]
18pub struct RestoreMapping {
19    pub members: Vec<RestoreMappingEntry>,
20}
21
22impl RestoreMapping {
23    /// Resolve the target canister for one source member.
24    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///
33/// RestoreMappingEntry
34///
35
36#[derive(Clone, Debug, Deserialize, Serialize)]
37pub struct RestoreMappingEntry {
38    pub source_canister: String,
39    pub target_canister: String,
40}
41
42///
43/// RestorePlan
44///
45
46#[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    /// Return all planned members in execution order.
58    #[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///
68/// RestorePhase
69///
70
71#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
72pub struct RestorePhase {
73    pub restore_group: u16,
74    pub members: Vec<RestorePlanMember>,
75}
76
77///
78/// RestorePlanMember
79///
80
81#[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
95///
96/// RestorePlanner
97///
98
99pub struct RestorePlanner;
100
101impl RestorePlanner {
102    /// Build a no-mutation restore plan from the manifest and optional target mapping.
103    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///
128/// RestorePlanError
129///
130
131#[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
164// Validate a user-supplied restore mapping before applying it to the manifest.
165fn 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
189// Ensure mappings only reference members declared in the manifest.
190fn 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
212// Resolve source manifest members into target restore members.
213fn 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
253// Resolve one member's target canister, enforcing identity continuity.
254fn 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
276// Group members and apply parent-before-child ordering inside each group.
277fn 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
297// Topologically order one group using manifest parent relationships.
298fn 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
326// Determine whether a member's in-group parent has already been emitted.
327fn 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
338// Validate textual principal fields used in mappings.
339fn 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    // Build one valid manifest with a parent and child in the same restore group.
362    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    // Build one manifest member for restore planning tests.
406    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            },
435        }
436    }
437
438    // Ensure in-place restore planning sorts parent before child.
439    #[test]
440    fn in_place_plan_orders_parent_before_child() {
441        let manifest = valid_manifest(IdentityMode::Relocatable);
442
443        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
444        let ordered = plan.ordered_members();
445
446        assert_eq!(plan.backup_id, "fbk_test_001");
447        assert_eq!(plan.source_environment, "local");
448        assert_eq!(plan.source_root_canister, ROOT);
449        assert_eq!(plan.topology_hash, HASH);
450        assert_eq!(plan.member_count, 2);
451        assert_eq!(ordered[0].source_canister, ROOT);
452        assert_eq!(ordered[1].source_canister, CHILD);
453    }
454
455    // Ensure fixed identities cannot be remapped.
456    #[test]
457    fn fixed_identity_member_cannot_be_remapped() {
458        let manifest = valid_manifest(IdentityMode::Fixed);
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: TARGET.to_string(),
468                },
469            ],
470        };
471
472        let err = RestorePlanner::plan(&manifest, Some(&mapping))
473            .expect_err("fixed member remap should fail");
474
475        assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
476    }
477
478    // Ensure relocatable identities may be mapped when all members are covered.
479    #[test]
480    fn relocatable_member_can_be_mapped() {
481        let manifest = valid_manifest(IdentityMode::Relocatable);
482        let mapping = RestoreMapping {
483            members: vec![
484                RestoreMappingEntry {
485                    source_canister: ROOT.to_string(),
486                    target_canister: ROOT.to_string(),
487                },
488                RestoreMappingEntry {
489                    source_canister: CHILD.to_string(),
490                    target_canister: TARGET.to_string(),
491                },
492            ],
493        };
494
495        let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
496        let child = plan
497            .ordered_members()
498            .into_iter()
499            .find(|member| member.source_canister == CHILD)
500            .expect("child member should be planned");
501
502        assert_eq!(child.target_canister, TARGET);
503        assert_eq!(child.parent_target_canister, Some(ROOT.to_string()));
504    }
505
506    // Ensure restore plans carry enough metadata for operator preflight.
507    #[test]
508    fn plan_members_include_snapshot_and_verification_metadata() {
509        let manifest = valid_manifest(IdentityMode::Relocatable);
510
511        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
512        let root = plan
513            .ordered_members()
514            .into_iter()
515            .find(|member| member.source_canister == ROOT)
516            .expect("root member should be planned");
517
518        assert_eq!(root.identity_mode, IdentityMode::Fixed);
519        assert_eq!(root.verification_class, "basic");
520        assert_eq!(root.verification_checks[0].kind, "call");
521        assert_eq!(root.source_snapshot.snapshot_id, "snap-root");
522        assert_eq!(root.source_snapshot.artifact_path, "artifacts/root");
523    }
524
525    // Ensure mapped restores must cover every source member.
526    #[test]
527    fn mapped_restore_requires_complete_mapping() {
528        let manifest = valid_manifest(IdentityMode::Relocatable);
529        let mapping = RestoreMapping {
530            members: vec![RestoreMappingEntry {
531                source_canister: ROOT.to_string(),
532                target_canister: ROOT.to_string(),
533            }],
534        };
535
536        let err = RestorePlanner::plan(&manifest, Some(&mapping))
537            .expect_err("incomplete mapping should fail");
538
539        assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
540    }
541
542    // Ensure mappings cannot silently include canisters outside the manifest.
543    #[test]
544    fn mapped_restore_rejects_unknown_mapping_sources() {
545        let manifest = valid_manifest(IdentityMode::Relocatable);
546        let unknown = "rdmx6-jaaaa-aaaaa-aaadq-cai";
547        let mapping = RestoreMapping {
548            members: vec![
549                RestoreMappingEntry {
550                    source_canister: ROOT.to_string(),
551                    target_canister: ROOT.to_string(),
552                },
553                RestoreMappingEntry {
554                    source_canister: CHILD.to_string(),
555                    target_canister: TARGET.to_string(),
556                },
557                RestoreMappingEntry {
558                    source_canister: unknown.to_string(),
559                    target_canister: unknown.to_string(),
560                },
561            ],
562        };
563
564        let err = RestorePlanner::plan(&manifest, Some(&mapping))
565            .expect_err("unknown mapping source should fail");
566
567        assert!(matches!(err, RestorePlanError::UnknownMappingSource(_)));
568    }
569
570    // Ensure duplicate target mappings fail before a plan is produced.
571    #[test]
572    fn duplicate_mapping_targets_fail_validation() {
573        let manifest = valid_manifest(IdentityMode::Relocatable);
574        let mapping = RestoreMapping {
575            members: vec![
576                RestoreMappingEntry {
577                    source_canister: ROOT.to_string(),
578                    target_canister: ROOT.to_string(),
579                },
580                RestoreMappingEntry {
581                    source_canister: CHILD.to_string(),
582                    target_canister: ROOT.to_string(),
583                },
584            ],
585        };
586
587        let err = RestorePlanner::plan(&manifest, Some(&mapping))
588            .expect_err("duplicate targets should fail");
589
590        assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
591    }
592}