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