1use super::{
8 BackupOperation, BackupOperationKind, BackupPlan, BackupPlanError, BackupScopeKind,
9 ControlAuthority, ControlAuthoritySource,
10};
11use candid::Principal;
12use std::{collections::BTreeSet, str::FromStr};
13
14impl BackupPlan {
15 pub fn validate(&self) -> Result<(), BackupPlanError> {
17 validate_nonempty("plan_id", &self.plan_id)?;
18 validate_nonempty("run_id", &self.run_id)?;
19 validate_nonempty("fleet", &self.fleet)?;
20 validate_nonempty("network", &self.network)?;
21 validate_principal("root_canister_id", &self.root_canister_id)?;
22 validate_optional_principal(
23 "selected_subtree_root",
24 self.selected_subtree_root.as_deref(),
25 )?;
26 validate_nonempty(
27 "topology_hash_before_quiesce",
28 &self.topology_hash_before_quiesce,
29 )?;
30 validate_root_scope(self)?;
31 validate_targets(self)?;
32 validate_selected_scope(self)?;
33 validate_phase_order(&self.phases)
34 }
35
36 pub fn validate_for_execution(&self) -> Result<(), BackupPlanError> {
38 self.validate()?;
39
40 for target in &self.targets {
41 if !target.control_authority.is_proven() {
42 return Err(BackupPlanError::UnprovenControlAuthority(
43 target.canister_id.clone(),
44 ));
45 }
46 if !target.snapshot_read_authority.is_proven() {
47 return Err(BackupPlanError::UnprovenTargetSnapshotReadAuthority(
48 target.canister_id.clone(),
49 ));
50 }
51 if self.requires_root_controller
52 && target.canister_id != self.root_canister_id
53 && !target.control_authority.is_proven_root_controller()
54 {
55 return Err(BackupPlanError::MissingRootController(
56 target.canister_id.clone(),
57 ));
58 }
59 }
60
61 Ok(())
62 }
63}
64
65fn validate_root_scope(plan: &BackupPlan) -> Result<(), BackupPlanError> {
66 if plan.selected_scope_kind == BackupScopeKind::MaintenanceRoot {
67 if plan.root_included {
68 return Ok(());
69 }
70 return Err(BackupPlanError::MaintenanceRootExcludesRoot);
71 }
72
73 if plan.root_included {
74 return Err(BackupPlanError::RootIncludedWithoutMaintenance);
75 }
76
77 Ok(())
78}
79
80fn validate_targets(plan: &BackupPlan) -> Result<(), BackupPlanError> {
81 if plan.targets.is_empty() {
82 return Err(BackupPlanError::EmptyTargets);
83 }
84
85 let mut target_ids = BTreeSet::new();
86 for target in &plan.targets {
87 validate_principal("targets[].canister_id", &target.canister_id)?;
88 validate_optional_principal(
89 "targets[].parent_canister_id",
90 target.parent_canister_id.as_deref(),
91 )?;
92 validate_optional_nonempty("targets[].role", target.role.as_deref())?;
93 validate_optional_nonempty(
94 "targets[].expected_module_hash",
95 target.expected_module_hash.as_deref(),
96 )?;
97 validate_control_authority(&target.control_authority)?;
98
99 if !target_ids.insert(target.canister_id.clone()) {
100 return Err(BackupPlanError::DuplicateTarget(target.canister_id.clone()));
101 }
102 if !plan.root_included && target.canister_id == plan.root_canister_id {
103 return Err(BackupPlanError::RootIncludedWithoutMaintenance);
104 }
105 }
106
107 validate_operation_targets(&plan.phases, &target_ids)
108}
109
110pub(super) fn validate_control_authority(
111 authority: &ControlAuthority,
112) -> Result<(), BackupPlanError> {
113 match &authority.source {
114 ControlAuthoritySource::Unknown
115 | ControlAuthoritySource::RootController
116 | ControlAuthoritySource::OperatorController => Ok(()),
117 ControlAuthoritySource::AlternateController { controller, reason } => {
118 validate_principal("targets[].control_authority.controller", controller)?;
119 validate_nonempty("targets[].control_authority.reason", reason)
120 }
121 }
122}
123
124fn validate_selected_scope(plan: &BackupPlan) -> Result<(), BackupPlanError> {
125 match plan.selected_scope_kind {
126 BackupScopeKind::NonRootDeployment => {
127 if plan.selected_subtree_root.is_some() {
128 return Err(BackupPlanError::NonRootDeploymentHasSelectedRoot);
129 }
130 Ok(())
131 }
132 BackupScopeKind::Member | BackupScopeKind::Subtree | BackupScopeKind::MaintenanceRoot => {
133 let Some(selected_root) = &plan.selected_subtree_root else {
134 return Err(BackupPlanError::EmptyField("selected_subtree_root"));
135 };
136 if plan
137 .targets
138 .iter()
139 .any(|target| &target.canister_id == selected_root)
140 {
141 Ok(())
142 } else {
143 Err(BackupPlanError::SelectedRootNotInTargets(
144 selected_root.clone(),
145 ))
146 }
147 }
148 }
149}
150
151fn validate_operation_targets(
152 phases: &[BackupOperation],
153 target_ids: &BTreeSet<String>,
154) -> Result<(), BackupPlanError> {
155 if phases.is_empty() {
156 return Err(BackupPlanError::EmptyPhases);
157 }
158
159 let mut operation_ids = BTreeSet::new();
160 for (index, phase) in phases.iter().enumerate() {
161 validate_nonempty("phases[].operation_id", &phase.operation_id)?;
162 let expected = u32::try_from(index).unwrap_or(u32::MAX);
163 if phase.order != expected {
164 return Err(BackupPlanError::OperationOrderMismatch {
165 operation_id: phase.operation_id.clone(),
166 order: phase.order,
167 expected,
168 });
169 }
170 if !operation_ids.insert(phase.operation_id.clone()) {
171 return Err(BackupPlanError::DuplicateOperationId(
172 phase.operation_id.clone(),
173 ));
174 }
175 if let Some(target) = &phase.target_canister_id {
176 validate_principal("phases[].target_canister_id", target)?;
177 if !target_ids.contains(target) {
178 return Err(BackupPlanError::UnknownOperationTarget {
179 operation_id: phase.operation_id.clone(),
180 target_canister_id: target.clone(),
181 });
182 }
183 }
184 }
185
186 Ok(())
187}
188
189fn validate_phase_order(phases: &[BackupOperation]) -> Result<(), BackupPlanError> {
190 let topology = preflight_position(phases, BackupOperationKind::ValidateTopology, "topology")?;
191 let control = preflight_position(
192 phases,
193 BackupOperationKind::ValidateControlAuthority,
194 "control_authority",
195 )?;
196 let read = preflight_position(
197 phases,
198 BackupOperationKind::ValidateSnapshotReadAuthority,
199 "snapshot_read_authority",
200 )?;
201 let quiescence = preflight_position(
202 phases,
203 BackupOperationKind::ValidateQuiescencePolicy,
204 "quiescence_policy",
205 )?;
206 let preflight_cutoff = [topology, control, read, quiescence]
207 .into_iter()
208 .max()
209 .expect("non-empty preflight positions");
210
211 for (index, phase) in phases.iter().enumerate() {
212 if index < preflight_cutoff && phase.kind.is_mutating() {
213 return Err(BackupPlanError::MutationBeforePreflight {
214 operation_id: phase.operation_id.clone(),
215 });
216 }
217 }
218
219 Ok(())
220}
221
222fn preflight_position(
223 phases: &[BackupOperation],
224 kind: BackupOperationKind,
225 label: &'static str,
226) -> Result<usize, BackupPlanError> {
227 phases
228 .iter()
229 .position(|phase| phase.kind == kind)
230 .ok_or(BackupPlanError::MissingPreflight(label))
231}
232
233impl BackupOperationKind {
234 const fn is_mutating(&self) -> bool {
235 matches!(
236 self,
237 Self::Stop | Self::CreateSnapshot | Self::Start | Self::DownloadSnapshot
238 )
239 }
240}
241
242pub(super) fn validate_nonempty(field: &'static str, value: &str) -> Result<(), BackupPlanError> {
243 if value.trim().is_empty() {
244 Err(BackupPlanError::EmptyField(field))
245 } else {
246 Ok(())
247 }
248}
249
250pub(super) fn validate_optional_nonempty(
251 field: &'static str,
252 value: Option<&str>,
253) -> Result<(), BackupPlanError> {
254 match value {
255 Some(value) => validate_nonempty(field, value),
256 None => Ok(()),
257 }
258}
259
260pub(super) fn validate_principal(field: &'static str, value: &str) -> Result<(), BackupPlanError> {
261 Principal::from_str(value)
262 .map(|_| ())
263 .map_err(|_| BackupPlanError::InvalidPrincipal {
264 field,
265 value: value.to_string(),
266 })
267}
268
269pub(super) fn validate_required_hash(
270 field: &'static str,
271 value: &str,
272) -> Result<(), BackupPlanError> {
273 validate_nonempty(field, value)?;
274 if value.len() == 64 && value.chars().all(|char| char.is_ascii_hexdigit()) {
275 Ok(())
276 } else {
277 Err(BackupPlanError::InvalidTopologyHash {
278 field,
279 value: value.to_string(),
280 })
281 }
282}
283
284pub(super) fn validate_preflight_id(value: &str) -> Result<(), BackupPlanError> {
285 validate_nonempty("preflight_id", value)
286}
287
288pub(super) fn validate_preflight_window(
289 preflight_id: &str,
290 validated_at: &str,
291 expires_at: &str,
292 as_of: &str,
293) -> Result<(), BackupPlanError> {
294 let validated_at_seconds =
295 validate_preflight_timestamp("preflight_receipts[].validated_at", validated_at)?;
296 let expires_at_seconds =
297 validate_preflight_timestamp("preflight_receipts[].expires_at", expires_at)?;
298 let as_of_seconds = validate_preflight_timestamp("preflight_receipts.as_of", as_of)?;
299
300 if validated_at_seconds >= expires_at_seconds {
301 return Err(BackupPlanError::PreflightReceiptInvalidWindow {
302 preflight_id: preflight_id.to_string(),
303 });
304 }
305 if as_of_seconds < validated_at_seconds {
306 return Err(BackupPlanError::PreflightReceiptNotYetValid {
307 preflight_id: preflight_id.to_string(),
308 validated_at: validated_at.to_string(),
309 as_of: as_of.to_string(),
310 });
311 }
312 if as_of_seconds >= expires_at_seconds {
313 return Err(BackupPlanError::PreflightReceiptExpired {
314 preflight_id: preflight_id.to_string(),
315 expires_at: expires_at.to_string(),
316 as_of: as_of.to_string(),
317 });
318 }
319
320 Ok(())
321}
322
323pub(super) fn validate_preflight_timestamp(
324 field: &'static str,
325 value: &str,
326) -> Result<u64, BackupPlanError> {
327 validate_nonempty(field, value)?;
328 value
329 .strip_prefix("unix:")
330 .and_then(|seconds| seconds.parse::<u64>().ok())
331 .ok_or_else(|| BackupPlanError::InvalidTimestamp {
332 field,
333 value: value.to_string(),
334 })
335}
336
337fn validate_optional_principal(
338 field: &'static str,
339 value: Option<&str>,
340) -> Result<(), BackupPlanError> {
341 match value {
342 Some(value) => validate_principal(field, value),
343 None => Ok(()),
344 }
345}