Skip to main content

canic_backup/restore/plan/
validation.rs

1//! Module: restore::plan::validation
2//!
3//! Responsibility: validate persisted restore plans and their derived projections.
4//! Does not own: plan construction, artifact validation, or restore execution.
5//! Boundary: persisted plan readers and apply preparation fail closed through typed causes.
6
7use super::{
8    RestorePlan, RestorePlanError, RestorePlanMember, order_members, restore_identity_summary,
9    restore_operation_summary, restore_ordering_summary, restore_readiness_summary,
10    restore_snapshot_summary, restore_verification_summary,
11};
12use crate::manifest::IdentityMode;
13use candid::Principal;
14use std::{
15    collections::{BTreeMap, BTreeSet},
16    str::FromStr,
17};
18
19const SUPPORTED_RESTORE_PLAN_VERSION: u16 = 1;
20const SHA256_ALGORITHM: &str = "sha256";
21
22impl RestorePlan {
23    /// Validate one persisted restore plan and every derived projection.
24    pub fn validate(&self) -> Result<(), RestorePlanError> {
25        if self.plan_version != SUPPORTED_RESTORE_PLAN_VERSION {
26            return Err(RestorePlanError::UnsupportedVersion(self.plan_version));
27        }
28        validate_nonempty("backup_id", &self.backup_id)?;
29        validate_nonempty("source_environment", &self.source_environment)?;
30        validate_principal("source_root_canister", &self.source_root_canister)?;
31        validate_hash("topology_hash", &self.topology_hash)?;
32        if self.member_count != self.members.len() {
33            return Err(RestorePlanError::MemberCountMismatch {
34                expected: self.members.len(),
35                actual: self.member_count,
36            });
37        }
38        if self.members.is_empty() {
39            return Err(RestorePlanError::EmptyField("members"));
40        }
41
42        validate_members(self)?;
43        validate_projections(self)
44    }
45}
46
47fn validate_members(plan: &RestorePlan) -> Result<(), RestorePlanError> {
48    let mut sources = BTreeSet::new();
49    let mut targets = BTreeSet::new();
50
51    for member in &plan.members {
52        validate_principal("members[].source_canister", &member.source_canister)?;
53        validate_principal("members[].target_canister", &member.target_canister)?;
54        validate_nonempty("members[].role", &member.role)?;
55        if !sources.insert(member.source_canister.clone()) {
56            return Err(RestorePlanError::DuplicatePlanSource(
57                member.source_canister.clone(),
58            ));
59        }
60        if !targets.insert(member.target_canister.clone()) {
61            return Err(RestorePlanError::DuplicatePlanTarget(
62                member.target_canister.clone(),
63            ));
64        }
65        if member.identity_mode == IdentityMode::Fixed
66            && member.source_canister != member.target_canister
67        {
68            return Err(RestorePlanError::FixedIdentityRemap {
69                source_canister: member.source_canister.clone(),
70                target_canister: member.target_canister.clone(),
71            });
72        }
73        validate_member_snapshot(member)?;
74    }
75
76    let source_targets = plan
77        .members
78        .iter()
79        .map(|member| {
80            (
81                member.source_canister.as_str(),
82                member.target_canister.as_str(),
83            )
84        })
85        .collect::<BTreeMap<_, _>>();
86
87    for member in &plan.members {
88        if let Some(parent_source) = &member.parent_source_canister {
89            validate_principal("members[].parent_source_canister", parent_source)?;
90            match source_targets.get(parent_source.as_str()) {
91                Some(expected_target)
92                    if member.parent_target_canister.as_deref() == Some(*expected_target) => {}
93                None if member.parent_target_canister.is_none() => {}
94                _ => {
95                    return Err(RestorePlanError::ProjectionMismatch(
96                        "members[].parent_target_canister",
97                    ));
98                }
99            }
100        } else if member.parent_target_canister.is_some() {
101            return Err(RestorePlanError::ProjectionMismatch(
102                "members[].parent_target_canister",
103            ));
104        }
105        if let Some(parent_target) = &member.parent_target_canister {
106            validate_principal("members[].parent_target_canister", parent_target)?;
107        }
108    }
109
110    for check in &plan.deployment_verification_checks {
111        validate_nonempty("deployment_verification_checks[].kind", &check.kind)?;
112    }
113
114    let canonical = order_members(plan.members.clone())?;
115    if canonical != plan.members {
116        return Err(RestorePlanError::ProjectionMismatch("members"));
117    }
118    Ok(())
119}
120
121fn validate_member_snapshot(member: &RestorePlanMember) -> Result<(), RestorePlanError> {
122    validate_nonempty(
123        "members[].source_snapshot.snapshot_id",
124        &member.source_snapshot.snapshot_id,
125    )?;
126    validate_nonempty(
127        "members[].source_snapshot.artifact_path",
128        &member.source_snapshot.artifact_path,
129    )?;
130    if member.source_snapshot.checksum_algorithm != SHA256_ALGORITHM {
131        return Err(RestorePlanError::ProjectionMismatch(
132            "members[].source_snapshot.checksum_algorithm",
133        ));
134    }
135    if let Some(checksum) = &member.source_snapshot.checksum {
136        validate_hash("members[].source_snapshot.checksum", checksum)?;
137    }
138    for check in &member.verification_checks {
139        validate_nonempty("members[].verification_checks[].kind", &check.kind)?;
140    }
141    Ok(())
142}
143
144fn validate_projections(plan: &RestorePlan) -> Result<(), RestorePlanError> {
145    let identity = restore_identity_summary(&plan.members, plan.identity_summary.mapping_supplied);
146    require_projection("identity_summary", &plan.identity_summary, &identity)?;
147    let snapshot = restore_snapshot_summary(&plan.members);
148    require_projection("snapshot_summary", &plan.snapshot_summary, &snapshot)?;
149    let verification =
150        restore_verification_summary(&plan.deployment_verification_checks, &plan.members);
151    require_projection(
152        "verification_summary",
153        &plan.verification_summary,
154        &verification,
155    )?;
156    let readiness = restore_readiness_summary(&snapshot, &verification);
157    require_projection("readiness_summary", &plan.readiness_summary, &readiness)?;
158    let operations = restore_operation_summary(plan.members.len(), &verification);
159    require_projection("operation_summary", &plan.operation_summary, &operations)?;
160    let ordering = restore_ordering_summary(&plan.members);
161    require_projection("ordering_summary", &plan.ordering_summary, &ordering)
162}
163
164fn require_projection<T: PartialEq>(
165    field: &'static str,
166    actual: &T,
167    expected: &T,
168) -> Result<(), RestorePlanError> {
169    if actual == expected {
170        Ok(())
171    } else {
172        Err(RestorePlanError::ProjectionMismatch(field))
173    }
174}
175
176fn validate_nonempty(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
177    if value.trim().is_empty() {
178        Err(RestorePlanError::EmptyField(field))
179    } else {
180        Ok(())
181    }
182}
183
184fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
185    validate_nonempty(field, value)?;
186    Principal::from_str(value)
187        .map(|_| ())
188        .map_err(|_| RestorePlanError::InvalidPrincipal {
189            field,
190            value: value.to_string(),
191        })
192}
193
194fn validate_hash(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
195    if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
196        Ok(())
197    } else {
198        Err(RestorePlanError::InvalidHash {
199            field,
200            value: value.to_string(),
201        })
202    }
203}