1use super::{BackupPlan, BackupPlanError, BackupScopeKind, BackupTarget, build_backup_phases};
8use candid::Principal;
9use std::{
10 collections::{BTreeMap, BTreeSet},
11 str::FromStr,
12};
13
14const SUPPORTED_BACKUP_PLAN_VERSION: u16 = 1;
15
16impl BackupPlan {
17 pub fn validate(&self) -> Result<(), BackupPlanError> {
19 if self.plan_version != SUPPORTED_BACKUP_PLAN_VERSION {
20 return Err(BackupPlanError::UnsupportedVersion(self.plan_version));
21 }
22 validate_nonempty("plan_id", &self.plan_id)?;
23 validate_nonempty("run_id", &self.run_id)?;
24 validate_nonempty("fleet", &self.fleet)?;
25 validate_nonempty("environment", &self.environment)?;
26 validate_principal("root_canister_id", &self.root_canister_id)?;
27 validate_optional_principal(
28 "selected_subtree_root",
29 self.selected_subtree_root.as_deref(),
30 )?;
31 validate_required_hash(
32 "topology_hash_before_quiesce",
33 &self.topology_hash_before_quiesce,
34 )?;
35 validate_root_scope(self)?;
36 validate_targets(self)?;
37 validate_selected_scope(self)?;
38 validate_target_topology(self)?;
39 validate_phase_projection(self)
40 }
41
42 pub fn validate_for_execution(&self) -> Result<(), BackupPlanError> {
44 self.validate()?;
45
46 for target in &self.targets {
47 if !target.control_authority.is_proven() {
48 return Err(BackupPlanError::UnprovenControlAuthority(
49 target.canister_id.clone(),
50 ));
51 }
52 if !target.snapshot_read_authority.is_proven() {
53 return Err(BackupPlanError::UnprovenTargetSnapshotReadAuthority(
54 target.canister_id.clone(),
55 ));
56 }
57 if self.requires_root_controller
58 && target.canister_id != self.root_canister_id
59 && !target.control_authority.is_proven_root_controller()
60 {
61 return Err(BackupPlanError::MissingRootController(
62 target.canister_id.clone(),
63 ));
64 }
65 }
66
67 Ok(())
68 }
69}
70
71fn validate_root_scope(plan: &BackupPlan) -> Result<(), BackupPlanError> {
72 if plan.selected_scope_kind == BackupScopeKind::MaintenanceRoot {
73 if plan.root_included {
74 return Ok(());
75 }
76 return Err(BackupPlanError::MaintenanceRootExcludesRoot);
77 }
78
79 if plan.root_included {
80 return Err(BackupPlanError::RootIncludedWithoutMaintenance);
81 }
82
83 Ok(())
84}
85
86fn validate_targets(plan: &BackupPlan) -> Result<(), BackupPlanError> {
87 if plan.targets.is_empty() {
88 return Err(BackupPlanError::EmptyTargets);
89 }
90
91 let mut target_ids = BTreeSet::new();
92 for target in &plan.targets {
93 validate_principal("targets[].canister_id", &target.canister_id)?;
94 validate_optional_principal(
95 "targets[].parent_canister_id",
96 target.parent_canister_id.as_deref(),
97 )?;
98 validate_optional_nonempty("targets[].role", target.role.as_deref())?;
99 validate_optional_nonempty(
100 "targets[].expected_module_hash",
101 target.expected_module_hash.as_deref(),
102 )?;
103 if !target_ids.insert(target.canister_id.clone()) {
104 return Err(BackupPlanError::DuplicateTarget(target.canister_id.clone()));
105 }
106 if !plan.root_included && target.canister_id == plan.root_canister_id {
107 return Err(BackupPlanError::RootIncludedWithoutMaintenance);
108 }
109 }
110
111 Ok(())
112}
113
114fn validate_selected_scope(plan: &BackupPlan) -> Result<(), BackupPlanError> {
115 match plan.selected_scope_kind {
116 BackupScopeKind::NonRootDeployment => {
117 if plan.selected_subtree_root.is_some() {
118 return Err(BackupPlanError::NonRootDeploymentHasSelectedRoot);
119 }
120 Ok(())
121 }
122 BackupScopeKind::Member | BackupScopeKind::Subtree | BackupScopeKind::MaintenanceRoot => {
123 let Some(selected_root) = &plan.selected_subtree_root else {
124 return Err(BackupPlanError::EmptyField("selected_subtree_root"));
125 };
126 if plan
127 .targets
128 .iter()
129 .any(|target| &target.canister_id == selected_root)
130 {
131 Ok(())
132 } else {
133 Err(BackupPlanError::SelectedRootNotInTargets(
134 selected_root.clone(),
135 ))
136 }
137 }
138 }
139}
140
141fn validate_target_topology(plan: &BackupPlan) -> Result<(), BackupPlanError> {
142 let targets = plan
143 .targets
144 .iter()
145 .map(|target| (target.canister_id.as_str(), target))
146 .collect::<BTreeMap<_, _>>();
147
148 for target in &plan.targets {
149 validate_target_parent_chain(target.canister_id.as_str(), &targets)?;
150 if let Some(parent_canister_id) = target.parent_canister_id.as_deref()
151 && let Some(parent) = targets.get(parent_canister_id)
152 {
153 let expected = u64::from(parent.depth) + 1;
154 if u64::from(target.depth) != expected {
155 return Err(BackupPlanError::TargetDepthMismatch {
156 canister_id: target.canister_id.clone(),
157 parent_canister_id: parent_canister_id.to_string(),
158 expected,
159 actual: target.depth,
160 });
161 }
162 }
163 }
164
165 if plan.selected_scope_kind == BackupScopeKind::NonRootDeployment {
166 for target in &plan.targets {
167 validate_target_connects_to_root(target, &plan.root_canister_id, &targets)?;
168 }
169 return Ok(());
170 }
171 let selected_root = plan
172 .selected_subtree_root
173 .as_deref()
174 .ok_or(BackupPlanError::EmptyField("selected_subtree_root"))?;
175 let selected = targets
176 .get(selected_root)
177 .ok_or_else(|| BackupPlanError::SelectedRootNotInTargets(selected_root.to_string()))?;
178 if let Some(parent_canister_id) = selected.parent_canister_id.as_deref()
179 && targets.contains_key(parent_canister_id)
180 {
181 return Err(BackupPlanError::SelectedRootHasInternalParent {
182 selected_root: selected_root.to_string(),
183 parent_canister_id: parent_canister_id.to_string(),
184 });
185 }
186
187 for target in &plan.targets {
188 if target.canister_id != selected_root {
189 validate_target_connects_to_root(target, selected_root, &targets)?;
190 }
191 }
192 Ok(())
193}
194
195fn validate_target_parent_chain(
196 canister_id: &str,
197 targets: &BTreeMap<&str, &BackupTarget>,
198) -> Result<(), BackupPlanError> {
199 let mut current = canister_id;
200 let mut seen = BTreeSet::new();
201 loop {
202 if !seen.insert(current) {
203 return Err(BackupPlanError::TargetParentCycle {
204 canister_id: canister_id.to_string(),
205 });
206 }
207 let Some(parent) = targets
208 .get(current)
209 .and_then(|target| target.parent_canister_id.as_deref())
210 .and_then(|parent| targets.get(parent))
211 else {
212 return Ok(());
213 };
214 current = parent.canister_id.as_str();
215 }
216}
217
218fn validate_target_connects_to_root(
219 target: &BackupTarget,
220 expected_root: &str,
221 targets: &BTreeMap<&str, &BackupTarget>,
222) -> Result<(), BackupPlanError> {
223 let mut current = target;
224 while let Some(parent_canister_id) = current.parent_canister_id.as_deref() {
225 if parent_canister_id == expected_root {
226 return Ok(());
227 }
228 let Some(parent) = targets.get(parent_canister_id) else {
229 break;
230 };
231 current = parent;
232 }
233
234 Err(BackupPlanError::TargetDisconnected {
235 canister_id: target.canister_id.clone(),
236 expected_root: expected_root.to_string(),
237 })
238}
239
240fn validate_phase_projection(plan: &BackupPlan) -> Result<(), BackupPlanError> {
241 let expected = build_backup_phases(&plan.targets);
242 if plan.phases.len() != expected.len() {
243 return Err(BackupPlanError::OperationCountMismatch {
244 expected: expected.len(),
245 actual: plan.phases.len(),
246 });
247 }
248
249 for (index, (actual, expected)) in plan.phases.iter().zip(expected).enumerate() {
250 let field = if actual.order != expected.order {
251 Some("order")
252 } else if actual.operation_id != expected.operation_id {
253 Some("operation_id")
254 } else if actual.kind != expected.kind {
255 Some("kind")
256 } else if actual.target_canister_id != expected.target_canister_id {
257 Some("target_canister_id")
258 } else {
259 None
260 };
261 if let Some(field) = field {
262 return Err(BackupPlanError::OperationProjectionMismatch { index, field });
263 }
264 }
265 Ok(())
266}
267
268pub(super) fn validate_nonempty(field: &'static str, value: &str) -> Result<(), BackupPlanError> {
269 if value.trim().is_empty() {
270 Err(BackupPlanError::EmptyField(field))
271 } else {
272 Ok(())
273 }
274}
275
276pub(super) fn validate_optional_nonempty(
277 field: &'static str,
278 value: Option<&str>,
279) -> Result<(), BackupPlanError> {
280 match value {
281 Some(value) => validate_nonempty(field, value),
282 None => Ok(()),
283 }
284}
285
286pub(super) fn validate_principal(field: &'static str, value: &str) -> Result<(), BackupPlanError> {
287 Principal::from_str(value)
288 .map(|_| ())
289 .map_err(|_| BackupPlanError::InvalidPrincipal {
290 field,
291 value: value.to_string(),
292 })
293}
294
295pub(super) fn validate_required_hash(
296 field: &'static str,
297 value: &str,
298) -> Result<(), BackupPlanError> {
299 validate_nonempty(field, value)?;
300 if value.len() == 64 && value.chars().all(|char| char.is_ascii_hexdigit()) {
301 Ok(())
302 } else {
303 Err(BackupPlanError::InvalidTopologyHash {
304 field,
305 value: value.to_string(),
306 })
307 }
308}
309
310pub(super) fn validate_preflight_id(value: &str) -> Result<(), BackupPlanError> {
311 validate_nonempty("preflight_id", value)
312}
313
314pub(super) fn validate_preflight_window(
315 preflight_id: &str,
316 validated_at: &str,
317 expires_at: &str,
318 as_of: &str,
319) -> Result<(), BackupPlanError> {
320 let validated_at_seconds =
321 validate_preflight_timestamp("preflight_receipts[].validated_at", validated_at)?;
322 let expires_at_seconds =
323 validate_preflight_timestamp("preflight_receipts[].expires_at", expires_at)?;
324 let as_of_seconds = validate_preflight_timestamp("preflight_receipts.as_of", as_of)?;
325
326 if validated_at_seconds >= expires_at_seconds {
327 return Err(BackupPlanError::PreflightReceiptInvalidWindow {
328 preflight_id: preflight_id.to_string(),
329 });
330 }
331 if as_of_seconds < validated_at_seconds {
332 return Err(BackupPlanError::PreflightReceiptNotYetValid {
333 preflight_id: preflight_id.to_string(),
334 validated_at: validated_at.to_string(),
335 as_of: as_of.to_string(),
336 });
337 }
338 if as_of_seconds >= expires_at_seconds {
339 return Err(BackupPlanError::PreflightReceiptExpired {
340 preflight_id: preflight_id.to_string(),
341 expires_at: expires_at.to_string(),
342 as_of: as_of.to_string(),
343 });
344 }
345
346 Ok(())
347}
348
349pub(super) fn validate_preflight_timestamp(
350 field: &'static str,
351 value: &str,
352) -> Result<u64, BackupPlanError> {
353 validate_nonempty(field, value)?;
354 value
355 .strip_prefix("unix:")
356 .and_then(|seconds| seconds.parse::<u64>().ok())
357 .ok_or_else(|| BackupPlanError::InvalidTimestamp {
358 field,
359 value: value.to_string(),
360 })
361}
362
363fn validate_optional_principal(
364 field: &'static str,
365 value: Option<&str>,
366) -> Result<(), BackupPlanError> {
367 match value {
368 Some(value) => validate_principal(field, value),
369 None => Ok(()),
370 }
371}