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