Skip to main content

canic_backup/persistence/
integrity.rs

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///
16/// BackupIntegrityReport
17///
18
19#[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///
30/// BackupExecutionIntegrityReport
31///
32
33#[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///
43/// ArtifactIntegrityReport
44///
45
46#[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///
55/// TopologyReceiptMismatch
56///
57
58#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
59struct TopologyReceiptMismatch {
60    field: String,
61    manifest: String,
62    journal: Option<String>,
63}
64
65// Verify cross-file backup layout consistency and artifact checksums.
66pub(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
212// Verify the execution journal is bound to the exact persisted backup plan.
213pub(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
284// Compare manifest and journal topology receipts for fail-closed verification.
285fn 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
305// Record one manifest/journal topology receipt mismatch.
306fn 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/// Resolve a backup artifact path under the backup root.
324#[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}