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