canic_backup/restore/plan/
validation.rs1use 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 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}