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 identity_summary: RestoreIdentitySummary,
54    pub verification_summary: RestoreVerificationSummary,
55    pub ordering_summary: RestoreOrderingSummary,
56    pub phases: Vec<RestorePhase>,
57}
58
59impl RestorePlan {
60    /// Return all planned members in execution order.
61    #[must_use]
62    pub fn ordered_members(&self) -> Vec<&RestorePlanMember> {
63        self.phases
64            .iter()
65            .flat_map(|phase| phase.members.iter())
66            .collect()
67    }
68}
69
70///
71/// RestoreIdentitySummary
72///
73
74#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
75pub struct RestoreIdentitySummary {
76    pub fixed_members: usize,
77    pub relocatable_members: usize,
78    pub in_place_members: usize,
79    pub mapped_members: usize,
80    pub remapped_members: usize,
81}
82
83///
84/// RestoreVerificationSummary
85///
86
87#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
88pub struct RestoreVerificationSummary {
89    pub fleet_checks: usize,
90    pub member_check_groups: usize,
91    pub member_checks: usize,
92    pub members_with_checks: usize,
93    pub total_checks: usize,
94}
95
96///
97/// RestoreOrderingSummary
98///
99
100#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
101pub struct RestoreOrderingSummary {
102    pub phase_count: usize,
103    pub dependency_free_members: usize,
104    pub in_group_parent_edges: usize,
105    pub cross_group_parent_edges: usize,
106}
107
108///
109/// RestorePhase
110///
111
112#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
113pub struct RestorePhase {
114    pub restore_group: u16,
115    pub members: Vec<RestorePlanMember>,
116}
117
118///
119/// RestorePlanMember
120///
121
122#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
123pub struct RestorePlanMember {
124    pub source_canister: String,
125    pub target_canister: String,
126    pub role: String,
127    pub parent_source_canister: Option<String>,
128    pub parent_target_canister: Option<String>,
129    pub ordering_dependency: Option<RestoreOrderingDependency>,
130    pub phase_order: usize,
131    pub restore_group: u16,
132    pub identity_mode: IdentityMode,
133    pub verification_class: String,
134    pub verification_checks: Vec<VerificationCheck>,
135    pub source_snapshot: SourceSnapshot,
136}
137
138///
139/// RestoreOrderingDependency
140///
141
142#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
143pub struct RestoreOrderingDependency {
144    pub source_canister: String,
145    pub target_canister: String,
146    pub relationship: RestoreOrderingRelationship,
147}
148
149///
150/// RestoreOrderingRelationship
151///
152
153#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
154#[serde(rename_all = "kebab-case")]
155pub enum RestoreOrderingRelationship {
156    ParentInSameGroup,
157    ParentInEarlierGroup,
158}
159
160///
161/// RestorePlanner
162///
163
164pub struct RestorePlanner;
165
166impl RestorePlanner {
167    /// Build a no-mutation restore plan from the manifest and optional target mapping.
168    pub fn plan(
169        manifest: &FleetBackupManifest,
170        mapping: Option<&RestoreMapping>,
171    ) -> Result<RestorePlan, RestorePlanError> {
172        manifest.validate()?;
173        if let Some(mapping) = mapping {
174            validate_mapping(mapping)?;
175            validate_mapping_sources(manifest, mapping)?;
176        }
177
178        let members = resolve_members(manifest, mapping)?;
179        let identity_summary = restore_identity_summary(&members, mapping.is_some());
180        let verification_summary = restore_verification_summary(manifest, &members);
181        validate_restore_group_dependencies(&members)?;
182        let phases = group_and_order_members(members)?;
183        let ordering_summary = restore_ordering_summary(&phases);
184
185        Ok(RestorePlan {
186            backup_id: manifest.backup_id.clone(),
187            source_environment: manifest.source.environment.clone(),
188            source_root_canister: manifest.source.root_canister.clone(),
189            topology_hash: manifest.fleet.topology_hash.clone(),
190            member_count: manifest.fleet.members.len(),
191            identity_summary,
192            verification_summary,
193            ordering_summary,
194            phases,
195        })
196    }
197}
198
199///
200/// RestorePlanError
201///
202
203#[derive(Debug, ThisError)]
204pub enum RestorePlanError {
205    #[error(transparent)]
206    InvalidManifest(#[from] ManifestValidationError),
207
208    #[error("field {field} must be a valid principal: {value}")]
209    InvalidPrincipal { field: &'static str, value: String },
210
211    #[error("mapping contains duplicate source canister {0}")]
212    DuplicateMappingSource(String),
213
214    #[error("mapping contains duplicate target canister {0}")]
215    DuplicateMappingTarget(String),
216
217    #[error("mapping references unknown source canister {0}")]
218    UnknownMappingSource(String),
219
220    #[error("mapping is missing source canister {0}")]
221    MissingMappingSource(String),
222
223    #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
224    FixedIdentityRemap {
225        source_canister: String,
226        target_canister: String,
227    },
228
229    #[error("restore plan contains duplicate target canister {0}")]
230    DuplicatePlanTarget(String),
231
232    #[error("restore group {0} contains a parent cycle or unresolved dependency")]
233    RestoreOrderCycle(u16),
234
235    #[error(
236        "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
237    )]
238    ParentRestoreGroupAfterChild {
239        child_source_canister: String,
240        parent_source_canister: String,
241        child_restore_group: u16,
242        parent_restore_group: u16,
243    },
244}
245
246// Validate a user-supplied restore mapping before applying it to the manifest.
247fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
248    let mut sources = BTreeSet::new();
249    let mut targets = BTreeSet::new();
250
251    for entry in &mapping.members {
252        validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
253        validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
254
255        if !sources.insert(entry.source_canister.clone()) {
256            return Err(RestorePlanError::DuplicateMappingSource(
257                entry.source_canister.clone(),
258            ));
259        }
260
261        if !targets.insert(entry.target_canister.clone()) {
262            return Err(RestorePlanError::DuplicateMappingTarget(
263                entry.target_canister.clone(),
264            ));
265        }
266    }
267
268    Ok(())
269}
270
271// Ensure mappings only reference members declared in the manifest.
272fn validate_mapping_sources(
273    manifest: &FleetBackupManifest,
274    mapping: &RestoreMapping,
275) -> Result<(), RestorePlanError> {
276    let sources = manifest
277        .fleet
278        .members
279        .iter()
280        .map(|member| member.canister_id.as_str())
281        .collect::<BTreeSet<_>>();
282
283    for entry in &mapping.members {
284        if !sources.contains(entry.source_canister.as_str()) {
285            return Err(RestorePlanError::UnknownMappingSource(
286                entry.source_canister.clone(),
287            ));
288        }
289    }
290
291    Ok(())
292}
293
294// Resolve source manifest members into target restore members.
295fn resolve_members(
296    manifest: &FleetBackupManifest,
297    mapping: Option<&RestoreMapping>,
298) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
299    let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
300    let mut targets = BTreeSet::new();
301    let mut source_to_target = BTreeMap::new();
302
303    for member in &manifest.fleet.members {
304        let target = resolve_target(member, mapping)?;
305        if !targets.insert(target.clone()) {
306            return Err(RestorePlanError::DuplicatePlanTarget(target));
307        }
308
309        source_to_target.insert(member.canister_id.clone(), target.clone());
310        plan_members.push(RestorePlanMember {
311            source_canister: member.canister_id.clone(),
312            target_canister: target,
313            role: member.role.clone(),
314            parent_source_canister: member.parent_canister_id.clone(),
315            parent_target_canister: None,
316            ordering_dependency: None,
317            phase_order: 0,
318            restore_group: member.restore_group,
319            identity_mode: member.identity_mode.clone(),
320            verification_class: member.verification_class.clone(),
321            verification_checks: member.verification_checks.clone(),
322            source_snapshot: member.source_snapshot.clone(),
323        });
324    }
325
326    for member in &mut plan_members {
327        member.parent_target_canister = member
328            .parent_source_canister
329            .as_ref()
330            .and_then(|parent| source_to_target.get(parent))
331            .cloned();
332    }
333
334    Ok(plan_members)
335}
336
337// Resolve one member's target canister, enforcing identity continuity.
338fn resolve_target(
339    member: &FleetMember,
340    mapping: Option<&RestoreMapping>,
341) -> Result<String, RestorePlanError> {
342    let target = match mapping {
343        Some(mapping) => mapping
344            .target_for(&member.canister_id)
345            .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
346            .to_string(),
347        None => member.canister_id.clone(),
348    };
349
350    if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
351        return Err(RestorePlanError::FixedIdentityRemap {
352            source_canister: member.canister_id.clone(),
353            target_canister: target,
354        });
355    }
356
357    Ok(target)
358}
359
360// Summarize identity and mapping decisions before grouping restore phases.
361fn restore_identity_summary(
362    members: &[RestorePlanMember],
363    mapping_supplied: bool,
364) -> RestoreIdentitySummary {
365    let mut summary = RestoreIdentitySummary {
366        fixed_members: 0,
367        relocatable_members: 0,
368        in_place_members: 0,
369        mapped_members: 0,
370        remapped_members: 0,
371    };
372
373    for member in members {
374        match member.identity_mode {
375            IdentityMode::Fixed => summary.fixed_members += 1,
376            IdentityMode::Relocatable => summary.relocatable_members += 1,
377        }
378
379        if member.source_canister == member.target_canister {
380            summary.in_place_members += 1;
381        } else {
382            summary.remapped_members += 1;
383        }
384        if mapping_supplied {
385            summary.mapped_members += 1;
386        }
387    }
388
389    summary
390}
391
392// Summarize restore verification work declared by the manifest and members.
393fn restore_verification_summary(
394    manifest: &FleetBackupManifest,
395    members: &[RestorePlanMember],
396) -> RestoreVerificationSummary {
397    let fleet_checks = manifest.verification.fleet_checks.len();
398    let member_check_groups = manifest.verification.member_checks.len();
399    let role_check_counts = manifest
400        .verification
401        .member_checks
402        .iter()
403        .map(|group| (group.role.as_str(), group.checks.len()))
404        .collect::<BTreeMap<_, _>>();
405    let inline_member_checks = members
406        .iter()
407        .map(|member| member.verification_checks.len())
408        .sum::<usize>();
409    let role_member_checks = members
410        .iter()
411        .map(|member| {
412            role_check_counts
413                .get(member.role.as_str())
414                .copied()
415                .unwrap_or(0)
416        })
417        .sum::<usize>();
418    let member_checks = inline_member_checks + role_member_checks;
419    let members_with_checks = members
420        .iter()
421        .filter(|member| {
422            !member.verification_checks.is_empty()
423                || role_check_counts.contains_key(member.role.as_str())
424        })
425        .count();
426
427    RestoreVerificationSummary {
428        fleet_checks,
429        member_check_groups,
430        member_checks,
431        members_with_checks,
432        total_checks: fleet_checks + member_checks,
433    }
434}
435
436// Reject group assignments that would restore a child before its parent.
437fn validate_restore_group_dependencies(
438    members: &[RestorePlanMember],
439) -> Result<(), RestorePlanError> {
440    let groups_by_source = members
441        .iter()
442        .map(|member| (member.source_canister.as_str(), member.restore_group))
443        .collect::<BTreeMap<_, _>>();
444
445    for member in members {
446        let Some(parent) = &member.parent_source_canister else {
447            continue;
448        };
449        let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
450            continue;
451        };
452
453        if *parent_group > member.restore_group {
454            return Err(RestorePlanError::ParentRestoreGroupAfterChild {
455                child_source_canister: member.source_canister.clone(),
456                parent_source_canister: parent.clone(),
457                child_restore_group: member.restore_group,
458                parent_restore_group: *parent_group,
459            });
460        }
461    }
462
463    Ok(())
464}
465
466// Group members and apply parent-before-child ordering inside each group.
467fn group_and_order_members(
468    members: Vec<RestorePlanMember>,
469) -> Result<Vec<RestorePhase>, RestorePlanError> {
470    let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
471    for member in members {
472        groups.entry(member.restore_group).or_default().push(member);
473    }
474
475    groups
476        .into_iter()
477        .map(|(restore_group, members)| {
478            let members = order_group(restore_group, members)?;
479            Ok(RestorePhase {
480                restore_group,
481                members,
482            })
483        })
484        .collect()
485}
486
487// Topologically order one group using manifest parent relationships.
488fn order_group(
489    restore_group: u16,
490    members: Vec<RestorePlanMember>,
491) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
492    let mut remaining = members;
493    let group_sources = remaining
494        .iter()
495        .map(|member| member.source_canister.clone())
496        .collect::<BTreeSet<_>>();
497    let mut emitted = BTreeSet::new();
498    let mut ordered = Vec::with_capacity(remaining.len());
499
500    while !remaining.is_empty() {
501        let Some(index) = remaining
502            .iter()
503            .position(|member| parent_satisfied(member, &group_sources, &emitted))
504        else {
505            return Err(RestorePlanError::RestoreOrderCycle(restore_group));
506        };
507
508        let mut member = remaining.remove(index);
509        member.phase_order = ordered.len();
510        member.ordering_dependency = ordering_dependency(&member, &group_sources);
511        emitted.insert(member.source_canister.clone());
512        ordered.push(member);
513    }
514
515    Ok(ordered)
516}
517
518// Describe the topology dependency that controlled a member's restore ordering.
519fn ordering_dependency(
520    member: &RestorePlanMember,
521    group_sources: &BTreeSet<String>,
522) -> Option<RestoreOrderingDependency> {
523    let parent_source = member.parent_source_canister.as_ref()?;
524    let parent_target = member.parent_target_canister.as_ref()?;
525    let relationship = if group_sources.contains(parent_source) {
526        RestoreOrderingRelationship::ParentInSameGroup
527    } else {
528        RestoreOrderingRelationship::ParentInEarlierGroup
529    };
530
531    Some(RestoreOrderingDependency {
532        source_canister: parent_source.clone(),
533        target_canister: parent_target.clone(),
534        relationship,
535    })
536}
537
538// Summarize the dependency ordering metadata exposed in the restore plan.
539fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
540    let mut summary = RestoreOrderingSummary {
541        phase_count: phases.len(),
542        dependency_free_members: 0,
543        in_group_parent_edges: 0,
544        cross_group_parent_edges: 0,
545    };
546
547    for member in phases.iter().flat_map(|phase| phase.members.iter()) {
548        match &member.ordering_dependency {
549            Some(dependency)
550                if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
551            {
552                summary.in_group_parent_edges += 1;
553            }
554            Some(dependency)
555                if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
556            {
557                summary.cross_group_parent_edges += 1;
558            }
559            Some(_) => {}
560            None => summary.dependency_free_members += 1,
561        }
562    }
563
564    summary
565}
566
567// Determine whether a member's in-group parent has already been emitted.
568fn parent_satisfied(
569    member: &RestorePlanMember,
570    group_sources: &BTreeSet<String>,
571    emitted: &BTreeSet<String>,
572) -> bool {
573    match &member.parent_source_canister {
574        Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
575        _ => true,
576    }
577}
578
579// Validate textual principal fields used in mappings.
580fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
581    Principal::from_str(value)
582        .map(|_| ())
583        .map_err(|_| RestorePlanError::InvalidPrincipal {
584            field,
585            value: value.to_string(),
586        })
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use crate::manifest::{
593        BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetSection,
594        MemberVerificationChecks, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
595        VerificationPlan,
596    };
597
598    const ROOT: &str = "aaaaa-aa";
599    const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
600    const CHILD_TWO: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";
601    const TARGET: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
602    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
603
604    // Build one valid manifest with a parent and child in the same restore group.
605    fn valid_manifest(identity_mode: IdentityMode) -> FleetBackupManifest {
606        FleetBackupManifest {
607            manifest_version: 1,
608            backup_id: "fbk_test_001".to_string(),
609            created_at: "2026-04-10T12:00:00Z".to_string(),
610            tool: ToolMetadata {
611                name: "canic".to_string(),
612                version: "v1".to_string(),
613            },
614            source: SourceMetadata {
615                environment: "local".to_string(),
616                root_canister: ROOT.to_string(),
617            },
618            consistency: ConsistencySection {
619                mode: ConsistencyMode::CrashConsistent,
620                backup_units: vec![BackupUnit {
621                    unit_id: "whole-fleet".to_string(),
622                    kind: BackupUnitKind::WholeFleet,
623                    roles: vec!["root".to_string(), "app".to_string()],
624                    consistency_reason: None,
625                    dependency_closure: Vec::new(),
626                    topology_validation: "subtree-closed".to_string(),
627                    quiescence_strategy: None,
628                }],
629            },
630            fleet: FleetSection {
631                topology_hash_algorithm: "sha256".to_string(),
632                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
633                discovery_topology_hash: HASH.to_string(),
634                pre_snapshot_topology_hash: HASH.to_string(),
635                topology_hash: HASH.to_string(),
636                members: vec![
637                    fleet_member("app", CHILD, Some(ROOT), identity_mode, 1),
638                    fleet_member("root", ROOT, None, IdentityMode::Fixed, 1),
639                ],
640            },
641            verification: VerificationPlan {
642                fleet_checks: Vec::new(),
643                member_checks: Vec::new(),
644            },
645        }
646    }
647
648    // Build one manifest member for restore planning tests.
649    fn fleet_member(
650        role: &str,
651        canister_id: &str,
652        parent_canister_id: Option<&str>,
653        identity_mode: IdentityMode,
654        restore_group: u16,
655    ) -> FleetMember {
656        FleetMember {
657            role: role.to_string(),
658            canister_id: canister_id.to_string(),
659            parent_canister_id: parent_canister_id.map(str::to_string),
660            subnet_canister_id: None,
661            controller_hint: Some(ROOT.to_string()),
662            identity_mode,
663            restore_group,
664            verification_class: "basic".to_string(),
665            verification_checks: vec![VerificationCheck {
666                kind: "call".to_string(),
667                method: Some("canic_ready".to_string()),
668                roles: Vec::new(),
669            }],
670            source_snapshot: SourceSnapshot {
671                snapshot_id: format!("snap-{role}"),
672                module_hash: Some(HASH.to_string()),
673                wasm_hash: Some(HASH.to_string()),
674                code_version: Some("v0.30.0".to_string()),
675                artifact_path: format!("artifacts/{role}"),
676                checksum_algorithm: "sha256".to_string(),
677                checksum: Some(HASH.to_string()),
678            },
679        }
680    }
681
682    // Ensure in-place restore planning sorts parent before child.
683    #[test]
684    fn in_place_plan_orders_parent_before_child() {
685        let manifest = valid_manifest(IdentityMode::Relocatable);
686
687        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
688        let ordered = plan.ordered_members();
689
690        assert_eq!(plan.backup_id, "fbk_test_001");
691        assert_eq!(plan.source_environment, "local");
692        assert_eq!(plan.source_root_canister, ROOT);
693        assert_eq!(plan.topology_hash, HASH);
694        assert_eq!(plan.member_count, 2);
695        assert_eq!(plan.identity_summary.fixed_members, 1);
696        assert_eq!(plan.identity_summary.relocatable_members, 1);
697        assert_eq!(plan.identity_summary.in_place_members, 2);
698        assert_eq!(plan.identity_summary.mapped_members, 0);
699        assert_eq!(plan.identity_summary.remapped_members, 0);
700        assert_eq!(plan.verification_summary.fleet_checks, 0);
701        assert_eq!(plan.verification_summary.member_check_groups, 0);
702        assert_eq!(plan.verification_summary.member_checks, 2);
703        assert_eq!(plan.verification_summary.members_with_checks, 2);
704        assert_eq!(plan.verification_summary.total_checks, 2);
705        assert_eq!(plan.ordering_summary.phase_count, 1);
706        assert_eq!(plan.ordering_summary.dependency_free_members, 1);
707        assert_eq!(plan.ordering_summary.in_group_parent_edges, 1);
708        assert_eq!(plan.ordering_summary.cross_group_parent_edges, 0);
709        assert_eq!(ordered[0].phase_order, 0);
710        assert_eq!(ordered[1].phase_order, 1);
711        assert_eq!(ordered[0].source_canister, ROOT);
712        assert_eq!(ordered[1].source_canister, CHILD);
713        assert_eq!(
714            ordered[1].ordering_dependency,
715            Some(RestoreOrderingDependency {
716                source_canister: ROOT.to_string(),
717                target_canister: ROOT.to_string(),
718                relationship: RestoreOrderingRelationship::ParentInSameGroup,
719            })
720        );
721    }
722
723    // Ensure cross-group parent dependencies are exposed when the parent phase is earlier.
724    #[test]
725    fn plan_reports_parent_dependency_from_earlier_group() {
726        let mut manifest = valid_manifest(IdentityMode::Relocatable);
727        manifest.fleet.members[0].restore_group = 2;
728        manifest.fleet.members[1].restore_group = 1;
729
730        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
731        let ordered = plan.ordered_members();
732
733        assert_eq!(plan.phases.len(), 2);
734        assert_eq!(plan.ordering_summary.phase_count, 2);
735        assert_eq!(plan.ordering_summary.dependency_free_members, 1);
736        assert_eq!(plan.ordering_summary.in_group_parent_edges, 0);
737        assert_eq!(plan.ordering_summary.cross_group_parent_edges, 1);
738        assert_eq!(ordered[0].source_canister, ROOT);
739        assert_eq!(ordered[1].source_canister, CHILD);
740        assert_eq!(
741            ordered[1].ordering_dependency,
742            Some(RestoreOrderingDependency {
743                source_canister: ROOT.to_string(),
744                target_canister: ROOT.to_string(),
745                relationship: RestoreOrderingRelationship::ParentInEarlierGroup,
746            })
747        );
748    }
749
750    // Ensure restore planning fails when groups would restore a child before its parent.
751    #[test]
752    fn plan_rejects_parent_in_later_restore_group() {
753        let mut manifest = valid_manifest(IdentityMode::Relocatable);
754        manifest.fleet.members[0].restore_group = 1;
755        manifest.fleet.members[1].restore_group = 2;
756
757        let err = RestorePlanner::plan(&manifest, None)
758            .expect_err("parent-after-child group ordering should fail");
759
760        assert!(matches!(
761            err,
762            RestorePlanError::ParentRestoreGroupAfterChild { .. }
763        ));
764    }
765
766    // Ensure fixed identities cannot be remapped.
767    #[test]
768    fn fixed_identity_member_cannot_be_remapped() {
769        let manifest = valid_manifest(IdentityMode::Fixed);
770        let mapping = RestoreMapping {
771            members: vec![
772                RestoreMappingEntry {
773                    source_canister: ROOT.to_string(),
774                    target_canister: ROOT.to_string(),
775                },
776                RestoreMappingEntry {
777                    source_canister: CHILD.to_string(),
778                    target_canister: TARGET.to_string(),
779                },
780            ],
781        };
782
783        let err = RestorePlanner::plan(&manifest, Some(&mapping))
784            .expect_err("fixed member remap should fail");
785
786        assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
787    }
788
789    // Ensure relocatable identities may be mapped when all members are covered.
790    #[test]
791    fn relocatable_member_can_be_mapped() {
792        let manifest = valid_manifest(IdentityMode::Relocatable);
793        let mapping = RestoreMapping {
794            members: vec![
795                RestoreMappingEntry {
796                    source_canister: ROOT.to_string(),
797                    target_canister: ROOT.to_string(),
798                },
799                RestoreMappingEntry {
800                    source_canister: CHILD.to_string(),
801                    target_canister: TARGET.to_string(),
802                },
803            ],
804        };
805
806        let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
807        let child = plan
808            .ordered_members()
809            .into_iter()
810            .find(|member| member.source_canister == CHILD)
811            .expect("child member should be planned");
812
813        assert_eq!(plan.identity_summary.fixed_members, 1);
814        assert_eq!(plan.identity_summary.relocatable_members, 1);
815        assert_eq!(plan.identity_summary.in_place_members, 1);
816        assert_eq!(plan.identity_summary.mapped_members, 2);
817        assert_eq!(plan.identity_summary.remapped_members, 1);
818        assert_eq!(child.target_canister, TARGET);
819        assert_eq!(child.parent_target_canister, Some(ROOT.to_string()));
820    }
821
822    // Ensure restore plans carry enough metadata for operator preflight.
823    #[test]
824    fn plan_members_include_snapshot_and_verification_metadata() {
825        let manifest = valid_manifest(IdentityMode::Relocatable);
826
827        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
828        let root = plan
829            .ordered_members()
830            .into_iter()
831            .find(|member| member.source_canister == ROOT)
832            .expect("root member should be planned");
833
834        assert_eq!(root.identity_mode, IdentityMode::Fixed);
835        assert_eq!(root.verification_class, "basic");
836        assert_eq!(root.verification_checks[0].kind, "call");
837        assert_eq!(root.source_snapshot.snapshot_id, "snap-root");
838        assert_eq!(root.source_snapshot.artifact_path, "artifacts/root");
839    }
840
841    // Ensure restore plans summarize manifest-level verification work.
842    #[test]
843    fn plan_includes_verification_summary() {
844        let mut manifest = valid_manifest(IdentityMode::Relocatable);
845        manifest.verification.fleet_checks.push(VerificationCheck {
846            kind: "fleet-ready".to_string(),
847            method: None,
848            roles: Vec::new(),
849        });
850        manifest
851            .verification
852            .member_checks
853            .push(MemberVerificationChecks {
854                role: "app".to_string(),
855                checks: vec![VerificationCheck {
856                    kind: "app-ready".to_string(),
857                    method: Some("ready".to_string()),
858                    roles: Vec::new(),
859                }],
860            });
861
862        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
863
864        assert_eq!(plan.verification_summary.fleet_checks, 1);
865        assert_eq!(plan.verification_summary.member_check_groups, 1);
866        assert_eq!(plan.verification_summary.member_checks, 3);
867        assert_eq!(plan.verification_summary.members_with_checks, 2);
868        assert_eq!(plan.verification_summary.total_checks, 4);
869    }
870
871    // Ensure role-level verification checks are counted once per matching member.
872    #[test]
873    fn plan_expands_role_verification_checks_per_matching_member() {
874        let mut manifest = valid_manifest(IdentityMode::Relocatable);
875        manifest.fleet.members.push(fleet_member(
876            "app",
877            CHILD_TWO,
878            Some(ROOT),
879            IdentityMode::Relocatable,
880            1,
881        ));
882        manifest
883            .verification
884            .member_checks
885            .push(MemberVerificationChecks {
886                role: "app".to_string(),
887                checks: vec![VerificationCheck {
888                    kind: "app-ready".to_string(),
889                    method: Some("ready".to_string()),
890                    roles: Vec::new(),
891                }],
892            });
893
894        let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
895
896        assert_eq!(plan.verification_summary.fleet_checks, 0);
897        assert_eq!(plan.verification_summary.member_check_groups, 1);
898        assert_eq!(plan.verification_summary.member_checks, 5);
899        assert_eq!(plan.verification_summary.members_with_checks, 3);
900        assert_eq!(plan.verification_summary.total_checks, 5);
901    }
902
903    // Ensure mapped restores must cover every source member.
904    #[test]
905    fn mapped_restore_requires_complete_mapping() {
906        let manifest = valid_manifest(IdentityMode::Relocatable);
907        let mapping = RestoreMapping {
908            members: vec![RestoreMappingEntry {
909                source_canister: ROOT.to_string(),
910                target_canister: ROOT.to_string(),
911            }],
912        };
913
914        let err = RestorePlanner::plan(&manifest, Some(&mapping))
915            .expect_err("incomplete mapping should fail");
916
917        assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
918    }
919
920    // Ensure mappings cannot silently include canisters outside the manifest.
921    #[test]
922    fn mapped_restore_rejects_unknown_mapping_sources() {
923        let manifest = valid_manifest(IdentityMode::Relocatable);
924        let unknown = "rdmx6-jaaaa-aaaaa-aaadq-cai";
925        let mapping = RestoreMapping {
926            members: vec![
927                RestoreMappingEntry {
928                    source_canister: ROOT.to_string(),
929                    target_canister: ROOT.to_string(),
930                },
931                RestoreMappingEntry {
932                    source_canister: CHILD.to_string(),
933                    target_canister: TARGET.to_string(),
934                },
935                RestoreMappingEntry {
936                    source_canister: unknown.to_string(),
937                    target_canister: unknown.to_string(),
938                },
939            ],
940        };
941
942        let err = RestorePlanner::plan(&manifest, Some(&mapping))
943            .expect_err("unknown mapping source should fail");
944
945        assert!(matches!(err, RestorePlanError::UnknownMappingSource(_)));
946    }
947
948    // Ensure duplicate target mappings fail before a plan is produced.
949    #[test]
950    fn duplicate_mapping_targets_fail_validation() {
951        let manifest = valid_manifest(IdentityMode::Relocatable);
952        let mapping = RestoreMapping {
953            members: vec![
954                RestoreMappingEntry {
955                    source_canister: ROOT.to_string(),
956                    target_canister: ROOT.to_string(),
957                },
958                RestoreMappingEntry {
959                    source_canister: CHILD.to_string(),
960                    target_canister: ROOT.to_string(),
961                },
962            ],
963        };
964
965        let err = RestorePlanner::plan(&manifest, Some(&mapping))
966            .expect_err("duplicate targets should fail");
967
968        assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
969    }
970}