Skip to main content

canic_backup/persistence/
mod.rs

1use crate::{
2    artifacts::{ArtifactChecksum, ArtifactChecksumError},
3    journal::{ArtifactState, DownloadJournal},
4    manifest::{FleetBackupManifest, ManifestValidationError},
5};
6use serde::{Deserialize, Serialize, de::DeserializeOwned};
7use std::{
8    collections::BTreeSet,
9    fs::{self, File},
10    io,
11    path::{Component, Path, PathBuf},
12};
13use thiserror::Error as ThisError;
14
15const MANIFEST_FILE_NAME: &str = "fleet-backup-manifest.json";
16const JOURNAL_FILE_NAME: &str = "download-journal.json";
17
18///
19/// BackupLayout
20///
21
22#[derive(Clone, Debug)]
23pub struct BackupLayout {
24    root: PathBuf,
25}
26
27impl BackupLayout {
28    /// Create a filesystem layout rooted at one backup directory.
29    #[must_use]
30    pub const fn new(root: PathBuf) -> Self {
31        Self { root }
32    }
33
34    /// Return the root backup directory path.
35    #[must_use]
36    pub fn root(&self) -> &Path {
37        &self.root
38    }
39
40    /// Return the canonical manifest path for this backup layout.
41    #[must_use]
42    pub fn manifest_path(&self) -> PathBuf {
43        self.root.join(MANIFEST_FILE_NAME)
44    }
45
46    /// Return the canonical mutable journal path for this backup layout.
47    #[must_use]
48    pub fn journal_path(&self) -> PathBuf {
49        self.root.join(JOURNAL_FILE_NAME)
50    }
51
52    /// Write a validated manifest with atomic replace semantics.
53    pub fn write_manifest(&self, manifest: &FleetBackupManifest) -> Result<(), PersistenceError> {
54        manifest.validate()?;
55        write_json_atomic(&self.manifest_path(), manifest)
56    }
57
58    /// Read and validate a manifest from this backup layout.
59    pub fn read_manifest(&self) -> Result<FleetBackupManifest, PersistenceError> {
60        let manifest = read_json(&self.manifest_path())?;
61        FleetBackupManifest::validate(&manifest)?;
62        Ok(manifest)
63    }
64
65    /// Write a validated download journal with atomic replace semantics.
66    pub fn write_journal(&self, journal: &DownloadJournal) -> Result<(), PersistenceError> {
67        journal.validate()?;
68        write_json_atomic(&self.journal_path(), journal)
69    }
70
71    /// Read and validate a download journal from this backup layout.
72    pub fn read_journal(&self) -> Result<DownloadJournal, PersistenceError> {
73        let journal = read_json(&self.journal_path())?;
74        DownloadJournal::validate(&journal)?;
75        Ok(journal)
76    }
77
78    /// Validate the manifest, journal, and durable artifact checksums.
79    pub fn verify_integrity(&self) -> Result<BackupIntegrityReport, PersistenceError> {
80        let manifest = self.read_manifest()?;
81        let journal = self.read_journal()?;
82        verify_layout_integrity(self, &manifest, &journal)
83    }
84}
85
86///
87/// TopologyReceiptMismatch
88///
89
90#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
91struct TopologyReceiptMismatch {
92    field: String,
93    manifest: String,
94    journal: Option<String>,
95}
96
97///
98/// BackupIntegrityReport
99///
100
101#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
102pub struct BackupIntegrityReport {
103    pub backup_id: String,
104    pub verified: bool,
105    pub manifest_members: usize,
106    pub journal_artifacts: usize,
107    pub durable_artifacts: usize,
108    pub artifacts: Vec<ArtifactIntegrityReport>,
109}
110
111///
112/// ArtifactIntegrityReport
113///
114
115#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
116pub struct ArtifactIntegrityReport {
117    pub canister_id: String,
118    pub snapshot_id: String,
119    pub artifact_path: String,
120    pub checksum: String,
121}
122
123///
124/// PersistenceError
125///
126
127#[derive(Debug, ThisError)]
128pub enum PersistenceError {
129    #[error(transparent)]
130    Io(#[from] io::Error),
131
132    #[error(transparent)]
133    Json(#[from] serde_json::Error),
134
135    #[error(transparent)]
136    InvalidManifest(#[from] ManifestValidationError),
137
138    #[error(transparent)]
139    InvalidJournal(#[from] crate::journal::JournalValidationError),
140
141    #[error(transparent)]
142    Checksum(#[from] ArtifactChecksumError),
143
144    #[error("manifest backup id {manifest} does not match journal backup id {journal}")]
145    BackupIdMismatch { manifest: String, journal: String },
146
147    #[error("journal artifact {canister_id} snapshot {snapshot_id} is not durable")]
148    NonDurableArtifact {
149        canister_id: String,
150        snapshot_id: String,
151    },
152
153    #[error("manifest member {canister_id} snapshot {snapshot_id} has no journal artifact")]
154    MissingJournalArtifact {
155        canister_id: String,
156        snapshot_id: String,
157    },
158
159    #[error("journal artifact {canister_id} snapshot {snapshot_id} is not declared in manifest")]
160    UnexpectedJournalArtifact {
161        canister_id: String,
162        snapshot_id: String,
163    },
164
165    #[error(
166        "manifest checksum for {canister_id} snapshot {snapshot_id} does not match journal checksum"
167    )]
168    ManifestJournalChecksumMismatch {
169        canister_id: String,
170        snapshot_id: String,
171        manifest: String,
172        journal: String,
173    },
174
175    #[error(
176        "manifest artifact path for {canister_id} snapshot {snapshot_id} does not match journal artifact path"
177    )]
178    ManifestJournalArtifactPathMismatch {
179        canister_id: String,
180        snapshot_id: String,
181        manifest: String,
182        journal: String,
183    },
184
185    #[error("manifest topology receipt {field} does not match journal topology receipt")]
186    ManifestJournalTopologyReceiptMismatch {
187        field: String,
188        manifest: String,
189        journal: Option<String>,
190    },
191
192    #[error("artifact path escapes backup root: {artifact_path}")]
193    ArtifactPathEscapesBackup { artifact_path: String },
194
195    #[error("artifact path does not exist: {0}")]
196    MissingArtifact(String),
197}
198
199// Verify cross-file backup layout consistency and artifact checksums.
200fn verify_layout_integrity(
201    layout: &BackupLayout,
202    manifest: &FleetBackupManifest,
203    journal: &DownloadJournal,
204) -> Result<BackupIntegrityReport, PersistenceError> {
205    if manifest.backup_id != journal.backup_id {
206        return Err(PersistenceError::BackupIdMismatch {
207            manifest: manifest.backup_id.clone(),
208            journal: journal.backup_id.clone(),
209        });
210    }
211
212    if let Some(mismatch) = topology_receipt_mismatches(manifest, journal)
213        .into_iter()
214        .next()
215    {
216        return Err(PersistenceError::ManifestJournalTopologyReceiptMismatch {
217            field: mismatch.field,
218            manifest: mismatch.manifest,
219            journal: mismatch.journal,
220        });
221    }
222
223    let expected_artifacts = manifest
224        .fleet
225        .members
226        .iter()
227        .map(|member| {
228            (
229                member.canister_id.as_str(),
230                member.source_snapshot.snapshot_id.as_str(),
231            )
232        })
233        .collect::<BTreeSet<_>>();
234    for entry in &journal.artifacts {
235        if !expected_artifacts.contains(&(entry.canister_id.as_str(), entry.snapshot_id.as_str())) {
236            return Err(PersistenceError::UnexpectedJournalArtifact {
237                canister_id: entry.canister_id.clone(),
238                snapshot_id: entry.snapshot_id.clone(),
239            });
240        }
241    }
242
243    let mut artifacts = Vec::with_capacity(journal.artifacts.len());
244    for member in &manifest.fleet.members {
245        let Some(entry) = journal.artifacts.iter().find(|entry| {
246            entry.canister_id == member.canister_id
247                && entry.snapshot_id == member.source_snapshot.snapshot_id
248        }) else {
249            return Err(PersistenceError::MissingJournalArtifact {
250                canister_id: member.canister_id.clone(),
251                snapshot_id: member.source_snapshot.snapshot_id.clone(),
252            });
253        };
254
255        if entry.state != ArtifactState::Durable {
256            return Err(PersistenceError::NonDurableArtifact {
257                canister_id: entry.canister_id.clone(),
258                snapshot_id: entry.snapshot_id.clone(),
259            });
260        }
261
262        let Some(expected_hash) = entry.checksum.as_deref() else {
263            unreachable!("validated durable journals must include checksums");
264        };
265        if member.source_snapshot.artifact_path != entry.artifact_path {
266            return Err(PersistenceError::ManifestJournalArtifactPathMismatch {
267                canister_id: entry.canister_id.clone(),
268                snapshot_id: entry.snapshot_id.clone(),
269                manifest: member.source_snapshot.artifact_path.clone(),
270                journal: entry.artifact_path.clone(),
271            });
272        }
273        if let Some(manifest_hash) = member.source_snapshot.checksum.as_deref()
274            && manifest_hash != expected_hash
275        {
276            return Err(PersistenceError::ManifestJournalChecksumMismatch {
277                canister_id: entry.canister_id.clone(),
278                snapshot_id: entry.snapshot_id.clone(),
279                manifest: manifest_hash.to_string(),
280                journal: expected_hash.to_string(),
281            });
282        }
283        let artifact_path = resolve_backup_artifact_path(layout.root(), &entry.artifact_path)
284            .ok_or_else(|| PersistenceError::ArtifactPathEscapesBackup {
285                artifact_path: entry.artifact_path.clone(),
286            })?;
287        if !artifact_path.exists() {
288            return Err(PersistenceError::MissingArtifact(
289                artifact_path.display().to_string(),
290            ));
291        }
292
293        ArtifactChecksum::from_path(&artifact_path)?.verify(expected_hash)?;
294        artifacts.push(ArtifactIntegrityReport {
295            canister_id: entry.canister_id.clone(),
296            snapshot_id: entry.snapshot_id.clone(),
297            artifact_path: artifact_path.display().to_string(),
298            checksum: expected_hash.to_string(),
299        });
300    }
301
302    Ok(BackupIntegrityReport {
303        backup_id: manifest.backup_id.clone(),
304        verified: true,
305        manifest_members: manifest.fleet.members.len(),
306        journal_artifacts: journal.artifacts.len(),
307        durable_artifacts: artifacts.len(),
308        artifacts,
309    })
310}
311
312// Compare manifest and journal topology receipts for fail-closed verification.
313fn topology_receipt_mismatches(
314    manifest: &FleetBackupManifest,
315    journal: &DownloadJournal,
316) -> Vec<TopologyReceiptMismatch> {
317    let mut mismatches = Vec::new();
318    record_topology_receipt_mismatch(
319        &mut mismatches,
320        "discovery_topology_hash",
321        &manifest.fleet.discovery_topology_hash,
322        journal.discovery_topology_hash.as_deref(),
323    );
324    record_topology_receipt_mismatch(
325        &mut mismatches,
326        "pre_snapshot_topology_hash",
327        &manifest.fleet.pre_snapshot_topology_hash,
328        journal.pre_snapshot_topology_hash.as_deref(),
329    );
330    mismatches
331}
332
333// Record one manifest/journal topology receipt mismatch.
334fn record_topology_receipt_mismatch(
335    mismatches: &mut Vec<TopologyReceiptMismatch>,
336    field: &str,
337    manifest: &str,
338    journal: Option<&str>,
339) {
340    if journal == Some(manifest) {
341        return;
342    }
343
344    mismatches.push(TopologyReceiptMismatch {
345        field: field.to_string(),
346        manifest: manifest.to_string(),
347        journal: journal.map(ToString::to_string),
348    });
349}
350
351/// Resolve a backup artifact path under the backup root.
352#[must_use]
353pub fn resolve_backup_artifact_path(root: &Path, artifact_path: &str) -> Option<PathBuf> {
354    let path = PathBuf::from(artifact_path);
355    if path.is_absolute() {
356        return None;
357    }
358    let is_safe = path
359        .components()
360        .all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
361    if !is_safe {
362        return None;
363    }
364
365    Some(root.join(path))
366}
367
368// Write JSON to a temporary sibling path and then atomically replace the target.
369fn write_json_atomic<T>(path: &Path, value: &T) -> Result<(), PersistenceError>
370where
371    T: Serialize,
372{
373    if let Some(parent) = path.parent() {
374        fs::create_dir_all(parent)?;
375    }
376
377    let tmp_path = temp_path_for(path);
378    let mut file = File::create(&tmp_path)?;
379    serde_json::to_writer_pretty(&mut file, value)?;
380    file.sync_all()?;
381    drop(file);
382
383    fs::rename(&tmp_path, path)?;
384
385    if let Some(parent) = path.parent() {
386        File::open(parent)?.sync_all()?;
387    }
388
389    Ok(())
390}
391
392// Read one JSON document from disk.
393fn read_json<T>(path: &Path) -> Result<T, PersistenceError>
394where
395    T: DeserializeOwned,
396{
397    let file = File::open(path)?;
398    Ok(serde_json::from_reader(file)?)
399}
400
401// Build the sibling temporary path used for atomic writes.
402fn temp_path_for(path: &Path) -> PathBuf {
403    let mut file_name = path
404        .file_name()
405        .and_then(|name| name.to_str())
406        .unwrap_or("canic-backup")
407        .to_string();
408    file_name.push_str(".tmp");
409    path.with_file_name(file_name)
410}
411
412#[cfg(test)]
413mod tests;