canic_backup/restore/apply/
validation.rs1use crate::{
8 artifacts::{ArtifactChecksum, ArtifactChecksumError},
9 restore::apply::{
10 RestoreApplyArtifactCheck, RestoreApplyDryRun, RestoreApplyDryRunOperation,
11 RestoreApplyOperationKind, RestoreApplyOperationKindCounts,
12 },
13};
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use thiserror::Error as ThisError;
18
19const SUPPORTED_DRY_RUN_VERSION: u16 = 1;
20
21#[derive(Debug, ThisError)]
29pub enum RestoreApplyDryRunValidationError {
30 #[error("restore apply dry-run field {field} has invalid artifact checksum")]
31 ArtifactChecksum {
32 field: &'static str,
33 #[source]
34 source: ArtifactChecksumError,
35 },
36
37 #[error("restore apply dry-run artifact check duplicates source canister {0}")]
38 DuplicateArtifactSource(String),
39
40 #[error("restore apply dry-run has duplicate operation sequence {0}")]
41 DuplicateSequence(usize),
42
43 #[error("restore apply dry-run field {0} is required")]
44 MissingField(&'static str),
45
46 #[error("restore apply dry-run is missing operation sequence {0}")]
47 MissingSequence(usize),
48
49 #[error("restore apply dry-run projection {0} does not match its concrete operations")]
50 ProjectionMismatch(&'static str),
51
52 #[error("restore apply dry-run ready state does not match readiness reasons")]
53 ReadinessMismatch,
54
55 #[error("unsupported restore apply dry-run version {0}")]
56 UnsupportedVersion(u16),
57}
58
59impl RestoreApplyDryRun {
60 pub fn validate(&self) -> Result<(), RestoreApplyDryRunValidationError> {
62 if self.dry_run_version != SUPPORTED_DRY_RUN_VERSION {
63 return Err(RestoreApplyDryRunValidationError::UnsupportedVersion(
64 self.dry_run_version,
65 ));
66 }
67 validate_nonempty("backup_id", &self.backup_id)?;
68 validate_readiness(self)?;
69 validate_operation_projections(self)?;
70 validate_operation_sequences(&self.operations)?;
71 for operation in &self.operations {
72 validate_operation_identity(operation)?;
73 }
74 validate_artifact_projection(self)
75 }
76}
77
78fn validate_readiness(
79 dry_run: &RestoreApplyDryRun,
80) -> Result<(), RestoreApplyDryRunValidationError> {
81 if dry_run.ready != dry_run.readiness_reasons.is_empty() {
82 return Err(RestoreApplyDryRunValidationError::ReadinessMismatch);
83 }
84
85 let mut reasons = BTreeSet::new();
86 for reason in &dry_run.readiness_reasons {
87 validate_nonempty("readiness_reasons[]", reason)?;
88 if !reasons.insert(reason) {
89 return Err(RestoreApplyDryRunValidationError::ProjectionMismatch(
90 "readiness_reasons",
91 ));
92 }
93 }
94 Ok(())
95}
96
97fn validate_operation_projections(
98 dry_run: &RestoreApplyDryRun,
99) -> Result<(), RestoreApplyDryRunValidationError> {
100 let counts = RestoreApplyOperationKindCounts::from_dry_run_operations(&dry_run.operations);
101 require_projection("operation_counts", dry_run.operation_counts == counts)?;
102 require_count(
103 "member_count.canister_stops",
104 dry_run.member_count,
105 counts.canister_stops,
106 )?;
107 require_count(
108 "member_count.canister_starts",
109 dry_run.member_count,
110 counts.canister_starts,
111 )?;
112 require_count(
113 "member_count.snapshot_uploads",
114 dry_run.member_count,
115 counts.snapshot_uploads,
116 )?;
117 require_count(
118 "member_count.snapshot_loads",
119 dry_run.member_count,
120 counts.snapshot_loads,
121 )?;
122 require_count(
123 "planned_canister_stops",
124 dry_run.planned_canister_stops,
125 counts.canister_stops,
126 )?;
127 require_count(
128 "planned_canister_starts",
129 dry_run.planned_canister_starts,
130 counts.canister_starts,
131 )?;
132 require_count(
133 "planned_snapshot_uploads",
134 dry_run.planned_snapshot_uploads,
135 counts.snapshot_uploads,
136 )?;
137 require_count(
138 "planned_snapshot_loads",
139 dry_run.planned_snapshot_loads,
140 counts.snapshot_loads,
141 )?;
142 require_count(
143 "planned_verification_checks",
144 dry_run.planned_verification_checks,
145 counts.verification_operations,
146 )?;
147 require_count(
148 "planned_operations",
149 dry_run.planned_operations,
150 dry_run.operations.len(),
151 )?;
152 require_count(
153 "rendered_operations",
154 dry_run.rendered_operations,
155 dry_run.operations.len(),
156 )
157}
158
159fn validate_operation_sequences(
160 operations: &[RestoreApplyDryRunOperation],
161) -> Result<(), RestoreApplyDryRunValidationError> {
162 let mut sequences = BTreeSet::new();
163 for operation in operations {
164 if !sequences.insert(operation.sequence) {
165 return Err(RestoreApplyDryRunValidationError::DuplicateSequence(
166 operation.sequence,
167 ));
168 }
169 }
170 for expected in 0..operations.len() {
171 if !sequences.contains(&expected) {
172 return Err(RestoreApplyDryRunValidationError::MissingSequence(expected));
173 }
174 }
175 Ok(())
176}
177
178fn validate_operation_identity(
179 operation: &RestoreApplyDryRunOperation,
180) -> Result<(), RestoreApplyDryRunValidationError> {
181 validate_nonempty("operations[].source_canister", &operation.source_canister)?;
182 validate_nonempty("operations[].target_canister", &operation.target_canister)?;
183 validate_nonempty("operations[].role", &operation.role)
184}
185
186fn validate_artifact_projection(
187 dry_run: &RestoreApplyDryRun,
188) -> Result<(), RestoreApplyDryRunValidationError> {
189 let Some(validation) = &dry_run.artifact_validation else {
190 return Ok(());
191 };
192
193 validate_nonempty("artifact_validation.backup_root", &validation.backup_root)?;
194 require_count(
195 "artifact_validation.checked_members",
196 validation.checked_members,
197 validation.checks.len(),
198 )?;
199 require_count(
200 "artifact_validation.checked_members",
201 validation.checked_members,
202 dry_run.member_count,
203 )?;
204
205 let uploads = upload_operations(&dry_run.operations)?;
206 require_count(
207 "artifact_validation.upload_operations",
208 uploads.len(),
209 dry_run.member_count,
210 )?;
211
212 let mut sources = BTreeSet::new();
213 for check in &validation.checks {
214 validate_artifact_check(check)?;
215 if !sources.insert(check.source_canister.as_str()) {
216 return Err(RestoreApplyDryRunValidationError::DuplicateArtifactSource(
217 check.source_canister.clone(),
218 ));
219 }
220 let upload = uploads.get(check.source_canister.as_str()).ok_or(
221 RestoreApplyDryRunValidationError::ProjectionMismatch(
222 "artifact_validation.checks[].source_canister",
223 ),
224 )?;
225 require_projection(
226 "artifact_validation.checks[]",
227 artifact_check_matches_upload(check, upload),
228 )?;
229 }
230
231 let artifacts_present = validation.checks.iter().all(|check| check.exists);
232 require_projection(
233 "artifact_validation.artifacts_present",
234 validation.artifacts_present == artifacts_present,
235 )?;
236 let expected_checksums = validation
237 .checks
238 .iter()
239 .filter(|check| check.checksum_expected.is_some())
240 .count();
241 require_count(
242 "artifact_validation.members_with_expected_checksums",
243 validation.members_with_expected_checksums,
244 expected_checksums,
245 )?;
246 let checksums_verified = expected_checksums == dry_run.member_count
247 && validation
248 .checks
249 .iter()
250 .all(|check| check.checksum_verified);
251 require_projection(
252 "artifact_validation.checksums_verified",
253 validation.checksums_verified == checksums_verified,
254 )
255}
256
257fn upload_operations(
258 operations: &[RestoreApplyDryRunOperation],
259) -> Result<BTreeMap<&str, &RestoreApplyDryRunOperation>, RestoreApplyDryRunValidationError> {
260 let mut uploads = BTreeMap::new();
261 for operation in operations
262 .iter()
263 .filter(|operation| operation.operation == RestoreApplyOperationKind::UploadSnapshot)
264 {
265 if uploads
266 .insert(operation.source_canister.as_str(), operation)
267 .is_some()
268 {
269 return Err(RestoreApplyDryRunValidationError::ProjectionMismatch(
270 "operations[].source_canister",
271 ));
272 }
273 }
274 Ok(uploads)
275}
276
277fn validate_artifact_check(
278 check: &RestoreApplyArtifactCheck,
279) -> Result<(), RestoreApplyDryRunValidationError> {
280 validate_nonempty(
281 "artifact_validation.checks[].source_canister",
282 &check.source_canister,
283 )?;
284 validate_nonempty(
285 "artifact_validation.checks[].target_canister",
286 &check.target_canister,
287 )?;
288 validate_nonempty(
289 "artifact_validation.checks[].snapshot_id",
290 &check.snapshot_id,
291 )?;
292 validate_nonempty(
293 "artifact_validation.checks[].artifact_path",
294 &check.artifact_path,
295 )?;
296 validate_nonempty(
297 "artifact_validation.checks[].resolved_path",
298 &check.resolved_path,
299 )?;
300 validate_checksum_algorithm(
301 "artifact_validation.checks[].checksum_algorithm",
302 &check.checksum_algorithm,
303 )?;
304 if let Some(expected) = &check.checksum_expected {
305 validate_checksum_hash("artifact_validation.checks[].checksum_expected", expected)?;
306 }
307 if let Some(actual) = &check.checksum_actual {
308 validate_checksum_hash("artifact_validation.checks[].checksum_actual", actual)?;
309 }
310 let checksum_verified = check.exists
311 && check.checksum_expected.is_some()
312 && check.checksum_expected == check.checksum_actual;
313 require_projection(
314 "artifact_validation.checks[].checksum_verified",
315 check.checksum_verified == checksum_verified,
316 )
317}
318
319fn artifact_check_matches_upload(
320 check: &RestoreApplyArtifactCheck,
321 upload: &RestoreApplyDryRunOperation,
322) -> bool {
323 let checksum_matches = match &upload.artifact_checksum {
324 Some(checksum) => {
325 checksum.algorithm == check.checksum_algorithm
326 && Some(checksum.hash.as_str()) == check.checksum_expected.as_deref()
327 }
328 None => check.checksum_expected.is_none(),
329 };
330
331 check.target_canister == upload.target_canister
332 && upload.snapshot_id.as_deref() == Some(check.snapshot_id.as_str())
333 && upload.artifact_path.as_deref() == Some(check.artifact_path.as_str())
334 && checksum_matches
335}
336
337fn validate_nonempty(
338 field: &'static str,
339 value: &str,
340) -> Result<(), RestoreApplyDryRunValidationError> {
341 require_projection(field, !value.trim().is_empty())
342 .map_err(|_| RestoreApplyDryRunValidationError::MissingField(field))
343}
344
345fn validate_checksum_algorithm(
346 field: &'static str,
347 algorithm: &str,
348) -> Result<(), RestoreApplyDryRunValidationError> {
349 ArtifactChecksum::validate_algorithm(algorithm)
350 .map_err(|source| RestoreApplyDryRunValidationError::ArtifactChecksum { field, source })
351}
352
353fn validate_checksum_hash(
354 field: &'static str,
355 hash: &str,
356) -> Result<(), RestoreApplyDryRunValidationError> {
357 ArtifactChecksum::validate_hash(hash)
358 .map_err(|source| RestoreApplyDryRunValidationError::ArtifactChecksum { field, source })
359}
360
361const fn require_count(
362 field: &'static str,
363 reported: usize,
364 actual: usize,
365) -> Result<(), RestoreApplyDryRunValidationError> {
366 require_projection(field, reported == actual)
367}
368
369const fn require_projection(
370 field: &'static str,
371 matches: bool,
372) -> Result<(), RestoreApplyDryRunValidationError> {
373 if matches {
374 Ok(())
375 } else {
376 Err(RestoreApplyDryRunValidationError::ProjectionMismatch(field))
377 }
378}