1use super::{BackupLayout, PersistenceError};
2use crate::{
3 artifacts::ArtifactChecksum,
4 execution::{
5 BackupExecutionJournal, BackupExecutionOperationReceiptOutcome,
6 BackupExecutionOperationState,
7 },
8 journal::{ArtifactState, DownloadJournal},
9 manifest::{FleetBackupManifest, FleetMember},
10 plan::{BackupOperationKind, BackupPlan},
11};
12use serde::{Deserialize, Serialize};
13use std::{
14 collections::BTreeSet,
15 path::{Component, Path, PathBuf},
16};
17
18#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
23pub struct BackupIntegrityReport {
24 pub backup_id: String,
25 pub verified: bool,
26 pub manifest_members: usize,
27 pub journal_artifacts: usize,
28 pub durable_artifacts: usize,
29 pub artifacts: Vec<ArtifactIntegrityReport>,
30}
31
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
37pub struct BackupExecutionIntegrityReport {
38 pub plan_id: String,
39 pub run_id: String,
40 pub verified: bool,
41 pub plan_operations: usize,
42 pub journal_operations: usize,
43}
44
45#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
50pub struct ArtifactIntegrityReport {
51 pub canister_id: String,
52 pub snapshot_id: String,
53 pub artifact_path: String,
54 pub checksum: String,
55}
56
57#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
62struct TopologyReceiptMismatch {
63 field: String,
64 manifest: String,
65 journal: Option<String>,
66}
67
68pub(super) fn verify_layout_integrity(
70 layout: &BackupLayout,
71 manifest: &FleetBackupManifest,
72 journal: &DownloadJournal,
73) -> Result<BackupIntegrityReport, PersistenceError> {
74 verify_manifest_journal_binding(manifest, journal)?;
75
76 let expected_artifacts = expected_artifact_keys(manifest);
77 for entry in &journal.artifacts {
78 if !expected_artifacts.contains(&(entry.canister_id.as_str(), entry.snapshot_id.as_str())) {
79 return Err(PersistenceError::UnexpectedJournalArtifact {
80 canister_id: entry.canister_id.clone(),
81 snapshot_id: entry.snapshot_id.clone(),
82 });
83 }
84 }
85
86 let mut artifacts = Vec::with_capacity(journal.artifacts.len());
87 for member in &manifest.fleet.members {
88 artifacts.push(verify_member_artifact(layout, journal, member)?);
89 }
90
91 Ok(BackupIntegrityReport {
92 backup_id: manifest.backup_id.clone(),
93 verified: true,
94 manifest_members: manifest.fleet.members.len(),
95 journal_artifacts: journal.artifacts.len(),
96 durable_artifacts: artifacts.len(),
97 artifacts,
98 })
99}
100
101fn verify_manifest_journal_binding(
102 manifest: &FleetBackupManifest,
103 journal: &DownloadJournal,
104) -> Result<(), PersistenceError> {
105 if manifest.backup_id != journal.backup_id {
106 return Err(PersistenceError::BackupIdMismatch {
107 manifest: manifest.backup_id.clone(),
108 journal: journal.backup_id.clone(),
109 });
110 }
111
112 if let Some(mismatch) = topology_receipt_mismatches(manifest, journal)
113 .into_iter()
114 .next()
115 {
116 return Err(PersistenceError::ManifestJournalTopologyReceiptMismatch {
117 field: mismatch.field,
118 manifest: mismatch.manifest,
119 journal: mismatch.journal,
120 });
121 }
122
123 Ok(())
124}
125
126fn expected_artifact_keys(manifest: &FleetBackupManifest) -> BTreeSet<(&str, &str)> {
127 manifest
128 .fleet
129 .members
130 .iter()
131 .map(|member| {
132 (
133 member.canister_id.as_str(),
134 member.source_snapshot.snapshot_id.as_str(),
135 )
136 })
137 .collect()
138}
139
140fn verify_member_artifact(
141 layout: &BackupLayout,
142 journal: &DownloadJournal,
143 member: &FleetMember,
144) -> Result<ArtifactIntegrityReport, PersistenceError> {
145 let Some(entry) = journal.artifacts.iter().find(|entry| {
146 entry.canister_id == member.canister_id
147 && entry.snapshot_id == member.source_snapshot.snapshot_id
148 }) else {
149 return Err(PersistenceError::MissingJournalArtifact {
150 canister_id: member.canister_id.clone(),
151 snapshot_id: member.source_snapshot.snapshot_id.clone(),
152 });
153 };
154
155 if entry.state != ArtifactState::Durable {
156 return Err(PersistenceError::NonDurableArtifact {
157 canister_id: entry.canister_id.clone(),
158 snapshot_id: entry.snapshot_id.clone(),
159 });
160 }
161
162 let expected_hash = entry.checksum.as_deref().ok_or_else(|| {
163 PersistenceError::MissingJournalArtifactChecksum {
164 canister_id: entry.canister_id.clone(),
165 snapshot_id: entry.snapshot_id.clone(),
166 }
167 })?;
168 validate_member_artifact_metadata(member, entry, expected_hash)?;
169 let artifact_path = resolve_backup_artifact_path(layout.root(), &entry.artifact_path)
170 .ok_or_else(|| PersistenceError::ArtifactPathEscapesBackup {
171 artifact_path: entry.artifact_path.clone(),
172 })?;
173 if !artifact_path.exists() {
174 return Err(PersistenceError::MissingArtifact(
175 artifact_path.display().to_string(),
176 ));
177 }
178
179 ArtifactChecksum::from_path(&artifact_path)?.verify(expected_hash)?;
180 Ok(ArtifactIntegrityReport {
181 canister_id: entry.canister_id.clone(),
182 snapshot_id: entry.snapshot_id.clone(),
183 artifact_path: artifact_path.display().to_string(),
184 checksum: expected_hash.to_string(),
185 })
186}
187
188fn validate_member_artifact_metadata(
189 member: &FleetMember,
190 entry: &crate::journal::ArtifactJournalEntry,
191 expected_hash: &str,
192) -> Result<(), PersistenceError> {
193 if member.source_snapshot.artifact_path != entry.artifact_path {
194 return Err(PersistenceError::ManifestJournalArtifactPathMismatch {
195 canister_id: entry.canister_id.clone(),
196 snapshot_id: entry.snapshot_id.clone(),
197 manifest: member.source_snapshot.artifact_path.clone(),
198 journal: entry.artifact_path.clone(),
199 });
200 }
201 if let Some(manifest_hash) = member.source_snapshot.checksum.as_deref()
202 && manifest_hash != expected_hash
203 {
204 return Err(PersistenceError::ManifestJournalChecksumMismatch {
205 canister_id: entry.canister_id.clone(),
206 snapshot_id: entry.snapshot_id.clone(),
207 manifest: manifest_hash.to_string(),
208 journal: expected_hash.to_string(),
209 });
210 }
211
212 Ok(())
213}
214
215pub(super) fn verify_execution_integrity(
217 plan: &BackupPlan,
218 journal: &BackupExecutionJournal,
219) -> Result<BackupExecutionIntegrityReport, PersistenceError> {
220 if plan.plan_id != journal.plan_id {
221 return Err(PersistenceError::PlanJournalMismatch {
222 field: "plan_id",
223 plan: plan.plan_id.clone(),
224 journal: journal.plan_id.clone(),
225 });
226 }
227 if plan.run_id != journal.run_id {
228 return Err(PersistenceError::PlanJournalMismatch {
229 field: "run_id",
230 plan: plan.run_id.clone(),
231 journal: journal.run_id.clone(),
232 });
233 }
234 if plan.phases.len() != journal.operations.len() {
235 return Err(PersistenceError::PlanJournalMismatch {
236 field: "operation_count",
237 plan: plan.phases.len().to_string(),
238 journal: journal.operations.len().to_string(),
239 });
240 }
241
242 for (phase, operation) in plan.phases.iter().zip(&journal.operations) {
243 let expected_sequence = usize::try_from(phase.order).unwrap_or(usize::MAX);
244 if expected_sequence != operation.sequence {
245 return Err(PersistenceError::PlanJournalOperationMismatch {
246 sequence: operation.sequence,
247 field: "sequence",
248 plan: expected_sequence.to_string(),
249 journal: operation.sequence.to_string(),
250 });
251 }
252 if phase.operation_id != operation.operation_id {
253 return Err(PersistenceError::PlanJournalOperationMismatch {
254 sequence: operation.sequence,
255 field: "operation_id",
256 plan: phase.operation_id.clone(),
257 journal: operation.operation_id.clone(),
258 });
259 }
260 if phase.kind != operation.kind {
261 return Err(PersistenceError::PlanJournalOperationMismatch {
262 sequence: operation.sequence,
263 field: "kind",
264 plan: format!("{:?}", phase.kind),
265 journal: format!("{:?}", operation.kind),
266 });
267 }
268 if phase.target_canister_id != operation.target_canister_id {
269 return Err(PersistenceError::PlanJournalOperationMismatch {
270 sequence: operation.sequence,
271 field: "target_canister_id",
272 plan: phase.target_canister_id.clone().unwrap_or_default(),
273 journal: operation.target_canister_id.clone().unwrap_or_default(),
274 });
275 }
276 }
277 verify_terminal_mutation_receipts(journal)?;
278
279 Ok(BackupExecutionIntegrityReport {
280 plan_id: plan.plan_id.clone(),
281 run_id: plan.run_id.clone(),
282 verified: true,
283 plan_operations: plan.phases.len(),
284 journal_operations: journal.operations.len(),
285 })
286}
287
288fn verify_terminal_mutation_receipts(
289 journal: &BackupExecutionJournal,
290) -> Result<(), PersistenceError> {
291 for operation in journal.operations.iter().filter(|operation| {
292 operation_kind_requires_receipt(&operation.kind)
293 && matches!(
294 operation.state,
295 BackupExecutionOperationState::Completed
296 | BackupExecutionOperationState::Failed
297 | BackupExecutionOperationState::Skipped
298 )
299 }) {
300 let expected_outcome = receipt_outcome_for_state(&operation.state);
301 let latest_receipt = journal
302 .operation_receipts
303 .iter()
304 .rev()
305 .find(|receipt| receipt.sequence == operation.sequence);
306 let Some(latest_receipt) = latest_receipt else {
307 return Err(PersistenceError::ExecutionOperationMissingReceipt {
308 sequence: operation.sequence,
309 state: format!("{:?}", operation.state),
310 });
311 };
312 let latest_matches = latest_receipt.operation_id == operation.operation_id
313 && latest_receipt.kind == operation.kind
314 && latest_receipt.target_canister_id == operation.target_canister_id
315 && latest_receipt.outcome == expected_outcome;
316 if !latest_matches {
317 return Err(PersistenceError::ExecutionOperationMissingReceipt {
318 sequence: operation.sequence,
319 state: format!("{:?}", operation.state),
320 });
321 }
322 if latest_receipt.updated_at.as_deref() != operation.state_updated_at.as_deref() {
323 return Err(
324 PersistenceError::ExecutionOperationReceiptTimestampMismatch {
325 sequence: operation.sequence,
326 },
327 );
328 }
329 }
330
331 Ok(())
332}
333
334const fn operation_kind_requires_receipt(kind: &BackupOperationKind) -> bool {
335 matches!(
336 kind,
337 BackupOperationKind::Stop
338 | BackupOperationKind::CreateSnapshot
339 | BackupOperationKind::Start
340 | BackupOperationKind::DownloadSnapshot
341 | BackupOperationKind::VerifyArtifact
342 | BackupOperationKind::FinalizeManifest
343 )
344}
345
346fn receipt_outcome_for_state(
347 state: &BackupExecutionOperationState,
348) -> BackupExecutionOperationReceiptOutcome {
349 match state {
350 BackupExecutionOperationState::Completed => {
351 BackupExecutionOperationReceiptOutcome::Completed
352 }
353 BackupExecutionOperationState::Failed => BackupExecutionOperationReceiptOutcome::Failed,
354 BackupExecutionOperationState::Skipped => BackupExecutionOperationReceiptOutcome::Skipped,
355 BackupExecutionOperationState::Ready
356 | BackupExecutionOperationState::Pending
357 | BackupExecutionOperationState::Blocked => {
358 unreachable!("non-terminal operation state does not have a receipt outcome")
359 }
360 }
361}
362
363fn topology_receipt_mismatches(
365 manifest: &FleetBackupManifest,
366 journal: &DownloadJournal,
367) -> Vec<TopologyReceiptMismatch> {
368 let mut mismatches = Vec::new();
369 record_topology_receipt_mismatch(
370 &mut mismatches,
371 "discovery_topology_hash",
372 &manifest.fleet.discovery_topology_hash,
373 journal.discovery_topology_hash.as_deref(),
374 );
375 record_topology_receipt_mismatch(
376 &mut mismatches,
377 "pre_snapshot_topology_hash",
378 &manifest.fleet.pre_snapshot_topology_hash,
379 journal.pre_snapshot_topology_hash.as_deref(),
380 );
381 mismatches
382}
383
384fn record_topology_receipt_mismatch(
386 mismatches: &mut Vec<TopologyReceiptMismatch>,
387 field: &str,
388 manifest: &str,
389 journal: Option<&str>,
390) {
391 if journal == Some(manifest) {
392 return;
393 }
394
395 mismatches.push(TopologyReceiptMismatch {
396 field: field.to_string(),
397 manifest: manifest.to_string(),
398 journal: journal.map(ToString::to_string),
399 });
400}
401
402#[must_use]
404pub fn resolve_backup_artifact_path(root: &Path, artifact_path: &str) -> Option<PathBuf> {
405 let path = PathBuf::from(artifact_path);
406 if path.is_absolute() {
407 return None;
408 }
409 let is_safe = path
410 .components()
411 .all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
412 if !is_safe {
413 return None;
414 }
415
416 Some(root.join(path))
417}