Skip to main content

canic_backup/persistence/
integrity.rs

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///
19/// BackupIntegrityReport
20///
21
22#[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///
33/// BackupExecutionIntegrityReport
34///
35
36#[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///
46/// ArtifactIntegrityReport
47///
48
49#[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///
58/// TopologyReceiptMismatch
59///
60
61#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
62struct TopologyReceiptMismatch {
63    field: String,
64    manifest: String,
65    journal: Option<String>,
66}
67
68// Verify cross-file backup layout consistency and artifact checksums.
69pub(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
215// Verify the execution journal is bound to the exact persisted backup plan.
216pub(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
363// Compare manifest and journal topology receipts for fail-closed verification.
364fn 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
384// Record one manifest/journal topology receipt mismatch.
385fn 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/// Resolve a backup artifact path under the backup root.
403#[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}