1use super::{BackupLayout, PersistenceError};
2use crate::{
3 artifacts::ArtifactChecksum,
4 execution::BackupExecutionJournal,
5 journal::{ArtifactState, DownloadJournal},
6 manifest::{FleetBackupManifest, FleetMember},
7 plan::BackupPlan,
8};
9use serde::{Deserialize, Serialize};
10use std::{
11 collections::BTreeSet,
12 path::{Component, Path, PathBuf},
13};
14
15#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
20pub struct BackupIntegrityReport {
21 pub backup_id: String,
22 pub verified: bool,
23 pub manifest_members: usize,
24 pub journal_artifacts: usize,
25 pub durable_artifacts: usize,
26 pub artifacts: Vec<ArtifactIntegrityReport>,
27}
28
29#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
34pub struct BackupExecutionIntegrityReport {
35 pub plan_id: String,
36 pub run_id: String,
37 pub verified: bool,
38 pub plan_operations: usize,
39 pub journal_operations: usize,
40}
41
42#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
47pub struct ArtifactIntegrityReport {
48 pub canister_id: String,
49 pub snapshot_id: String,
50 pub artifact_path: String,
51 pub checksum: String,
52}
53
54#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
59struct TopologyReceiptMismatch {
60 field: String,
61 manifest: String,
62 journal: Option<String>,
63}
64
65pub(super) fn verify_layout_integrity(
67 layout: &BackupLayout,
68 manifest: &FleetBackupManifest,
69 journal: &DownloadJournal,
70) -> Result<BackupIntegrityReport, PersistenceError> {
71 verify_manifest_journal_binding(manifest, journal)?;
72
73 let expected_artifacts = expected_artifact_keys(manifest);
74 for entry in &journal.artifacts {
75 if !expected_artifacts.contains(&(entry.canister_id.as_str(), entry.snapshot_id.as_str())) {
76 return Err(PersistenceError::UnexpectedJournalArtifact {
77 canister_id: entry.canister_id.clone(),
78 snapshot_id: entry.snapshot_id.clone(),
79 });
80 }
81 }
82
83 let mut artifacts = Vec::with_capacity(journal.artifacts.len());
84 for member in &manifest.fleet.members {
85 artifacts.push(verify_member_artifact(layout, journal, member)?);
86 }
87
88 Ok(BackupIntegrityReport {
89 backup_id: manifest.backup_id.clone(),
90 verified: true,
91 manifest_members: manifest.fleet.members.len(),
92 journal_artifacts: journal.artifacts.len(),
93 durable_artifacts: artifacts.len(),
94 artifacts,
95 })
96}
97
98fn verify_manifest_journal_binding(
99 manifest: &FleetBackupManifest,
100 journal: &DownloadJournal,
101) -> Result<(), PersistenceError> {
102 if manifest.backup_id != journal.backup_id {
103 return Err(PersistenceError::BackupIdMismatch {
104 manifest: manifest.backup_id.clone(),
105 journal: journal.backup_id.clone(),
106 });
107 }
108
109 if let Some(mismatch) = topology_receipt_mismatches(manifest, journal)
110 .into_iter()
111 .next()
112 {
113 return Err(PersistenceError::ManifestJournalTopologyReceiptMismatch {
114 field: mismatch.field,
115 manifest: mismatch.manifest,
116 journal: mismatch.journal,
117 });
118 }
119
120 Ok(())
121}
122
123fn expected_artifact_keys(manifest: &FleetBackupManifest) -> BTreeSet<(&str, &str)> {
124 manifest
125 .fleet
126 .members
127 .iter()
128 .map(|member| {
129 (
130 member.canister_id.as_str(),
131 member.source_snapshot.snapshot_id.as_str(),
132 )
133 })
134 .collect()
135}
136
137fn verify_member_artifact(
138 layout: &BackupLayout,
139 journal: &DownloadJournal,
140 member: &FleetMember,
141) -> Result<ArtifactIntegrityReport, PersistenceError> {
142 let Some(entry) = journal.artifacts.iter().find(|entry| {
143 entry.canister_id == member.canister_id
144 && entry.snapshot_id == member.source_snapshot.snapshot_id
145 }) else {
146 return Err(PersistenceError::MissingJournalArtifact {
147 canister_id: member.canister_id.clone(),
148 snapshot_id: member.source_snapshot.snapshot_id.clone(),
149 });
150 };
151
152 if entry.state != ArtifactState::Durable {
153 return Err(PersistenceError::NonDurableArtifact {
154 canister_id: entry.canister_id.clone(),
155 snapshot_id: entry.snapshot_id.clone(),
156 });
157 }
158
159 let expected_hash = entry.checksum.as_deref().ok_or_else(|| {
160 PersistenceError::MissingJournalArtifactChecksum {
161 canister_id: entry.canister_id.clone(),
162 snapshot_id: entry.snapshot_id.clone(),
163 }
164 })?;
165 validate_member_artifact_metadata(member, entry, expected_hash)?;
166 let artifact_path = resolve_backup_artifact_path(layout.root(), &entry.artifact_path)
167 .ok_or_else(|| PersistenceError::ArtifactPathEscapesBackup {
168 artifact_path: entry.artifact_path.clone(),
169 })?;
170 if !artifact_path.exists() {
171 return Err(PersistenceError::MissingArtifact(
172 artifact_path.display().to_string(),
173 ));
174 }
175
176 ArtifactChecksum::from_path(&artifact_path)?.verify(expected_hash)?;
177 Ok(ArtifactIntegrityReport {
178 canister_id: entry.canister_id.clone(),
179 snapshot_id: entry.snapshot_id.clone(),
180 artifact_path: artifact_path.display().to_string(),
181 checksum: expected_hash.to_string(),
182 })
183}
184
185fn validate_member_artifact_metadata(
186 member: &FleetMember,
187 entry: &crate::journal::ArtifactJournalEntry,
188 expected_hash: &str,
189) -> Result<(), PersistenceError> {
190 if member.source_snapshot.artifact_path != entry.artifact_path {
191 return Err(PersistenceError::ManifestJournalArtifactPathMismatch {
192 canister_id: entry.canister_id.clone(),
193 snapshot_id: entry.snapshot_id.clone(),
194 manifest: member.source_snapshot.artifact_path.clone(),
195 journal: entry.artifact_path.clone(),
196 });
197 }
198 if let Some(manifest_hash) = member.source_snapshot.checksum.as_deref()
199 && manifest_hash != expected_hash
200 {
201 return Err(PersistenceError::ManifestJournalChecksumMismatch {
202 canister_id: entry.canister_id.clone(),
203 snapshot_id: entry.snapshot_id.clone(),
204 manifest: manifest_hash.to_string(),
205 journal: expected_hash.to_string(),
206 });
207 }
208
209 Ok(())
210}
211
212pub(super) fn verify_execution_integrity(
214 plan: &BackupPlan,
215 journal: &BackupExecutionJournal,
216) -> Result<BackupExecutionIntegrityReport, PersistenceError> {
217 if plan.plan_id != journal.plan_id {
218 return Err(PersistenceError::PlanJournalMismatch {
219 field: "plan_id",
220 plan: plan.plan_id.clone(),
221 journal: journal.plan_id.clone(),
222 });
223 }
224 if plan.run_id != journal.run_id {
225 return Err(PersistenceError::PlanJournalMismatch {
226 field: "run_id",
227 plan: plan.run_id.clone(),
228 journal: journal.run_id.clone(),
229 });
230 }
231 if plan.phases.len() != journal.operations.len() {
232 return Err(PersistenceError::PlanJournalMismatch {
233 field: "operation_count",
234 plan: plan.phases.len().to_string(),
235 journal: journal.operations.len().to_string(),
236 });
237 }
238
239 for (phase, operation) in plan.phases.iter().zip(&journal.operations) {
240 let expected_sequence = usize::try_from(phase.order).unwrap_or(usize::MAX);
241 if expected_sequence != operation.sequence {
242 return Err(PersistenceError::PlanJournalOperationMismatch {
243 sequence: operation.sequence,
244 field: "sequence",
245 plan: expected_sequence.to_string(),
246 journal: operation.sequence.to_string(),
247 });
248 }
249 if phase.operation_id != operation.operation_id {
250 return Err(PersistenceError::PlanJournalOperationMismatch {
251 sequence: operation.sequence,
252 field: "operation_id",
253 plan: phase.operation_id.clone(),
254 journal: operation.operation_id.clone(),
255 });
256 }
257 if phase.kind != operation.kind {
258 return Err(PersistenceError::PlanJournalOperationMismatch {
259 sequence: operation.sequence,
260 field: "kind",
261 plan: format!("{:?}", phase.kind),
262 journal: format!("{:?}", operation.kind),
263 });
264 }
265 if phase.target_canister_id != operation.target_canister_id {
266 return Err(PersistenceError::PlanJournalOperationMismatch {
267 sequence: operation.sequence,
268 field: "target_canister_id",
269 plan: phase.target_canister_id.clone().unwrap_or_default(),
270 journal: operation.target_canister_id.clone().unwrap_or_default(),
271 });
272 }
273 }
274
275 Ok(BackupExecutionIntegrityReport {
276 plan_id: plan.plan_id.clone(),
277 run_id: plan.run_id.clone(),
278 verified: true,
279 plan_operations: plan.phases.len(),
280 journal_operations: journal.operations.len(),
281 })
282}
283
284fn topology_receipt_mismatches(
286 manifest: &FleetBackupManifest,
287 journal: &DownloadJournal,
288) -> Vec<TopologyReceiptMismatch> {
289 let mut mismatches = Vec::new();
290 record_topology_receipt_mismatch(
291 &mut mismatches,
292 "discovery_topology_hash",
293 &manifest.fleet.discovery_topology_hash,
294 journal.discovery_topology_hash.as_deref(),
295 );
296 record_topology_receipt_mismatch(
297 &mut mismatches,
298 "pre_snapshot_topology_hash",
299 &manifest.fleet.pre_snapshot_topology_hash,
300 journal.pre_snapshot_topology_hash.as_deref(),
301 );
302 mismatches
303}
304
305fn record_topology_receipt_mismatch(
307 mismatches: &mut Vec<TopologyReceiptMismatch>,
308 field: &str,
309 manifest: &str,
310 journal: Option<&str>,
311) {
312 if journal == Some(manifest) {
313 return;
314 }
315
316 mismatches.push(TopologyReceiptMismatch {
317 field: field.to_string(),
318 manifest: manifest.to_string(),
319 journal: journal.map(ToString::to_string),
320 });
321}
322
323#[must_use]
325pub fn resolve_backup_artifact_path(root: &Path, artifact_path: &str) -> Option<PathBuf> {
326 let path = PathBuf::from(artifact_path);
327 if path.is_absolute() {
328 return None;
329 }
330 let is_safe = path
331 .components()
332 .all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
333 if !is_safe {
334 return None;
335 }
336
337 Some(root.join(path))
338}