Skip to main content

canic_backup/persistence/
mod.rs

1mod integrity;
2
3pub use integrity::{
4    ArtifactIntegrityReport, BackupExecutionIntegrityReport, BackupIntegrityReport,
5    resolve_backup_artifact_path,
6};
7use integrity::{verify_execution_integrity, verify_layout_integrity};
8
9#[cfg(test)]
10use crate::artifacts::ArtifactChecksum;
11use crate::{
12    artifacts::ArtifactChecksumError,
13    execution::{BackupExecutionJournal, BackupExecutionJournalError},
14    journal::DownloadJournal,
15    manifest::{DeploymentBackupManifest, ManifestValidationError},
16    plan::{BackupPlan, BackupPlanError},
17};
18use serde::Serialize;
19use serde::de::DeserializeOwned;
20use std::{
21    fs::{self, File},
22    io,
23    path::{Path, PathBuf},
24};
25use thiserror::Error as ThisError;
26
27const MANIFEST_FILE_NAME: &str = "deployment-backup-manifest.json";
28const BACKUP_PLAN_FILE_NAME: &str = "backup-plan.json";
29const JOURNAL_FILE_NAME: &str = "download-journal.json";
30const EXECUTION_JOURNAL_FILE_NAME: &str = "backup-execution-journal.json";
31
32///
33/// BackupLayout
34///
35
36#[derive(Clone, Debug)]
37pub struct BackupLayout {
38    root: PathBuf,
39}
40
41impl BackupLayout {
42    /// Create a filesystem layout rooted at one backup directory.
43    #[must_use]
44    pub const fn new(root: PathBuf) -> Self {
45        Self { root }
46    }
47
48    /// Return the root backup directory path.
49    #[must_use]
50    pub fn root(&self) -> &Path {
51        &self.root
52    }
53
54    /// Return the canonical manifest path for this backup layout.
55    #[must_use]
56    pub fn manifest_path(&self) -> PathBuf {
57        self.root.join(MANIFEST_FILE_NAME)
58    }
59
60    /// Return the canonical backup plan path for this layout.
61    #[must_use]
62    pub fn backup_plan_path(&self) -> PathBuf {
63        self.root.join(BACKUP_PLAN_FILE_NAME)
64    }
65
66    /// Return the canonical mutable journal path for this backup layout.
67    #[must_use]
68    pub fn journal_path(&self) -> PathBuf {
69        self.root.join(JOURNAL_FILE_NAME)
70    }
71
72    /// Return the canonical backup execution journal path for this layout.
73    #[must_use]
74    pub fn execution_journal_path(&self) -> PathBuf {
75        self.root.join(EXECUTION_JOURNAL_FILE_NAME)
76    }
77
78    /// Write a validated manifest with atomic replace semantics.
79    pub fn write_manifest(
80        &self,
81        manifest: &DeploymentBackupManifest,
82    ) -> Result<(), PersistenceError> {
83        manifest.validate()?;
84        write_json_atomic(&self.manifest_path(), manifest)
85    }
86
87    /// Read and validate a manifest from this backup layout.
88    pub fn read_manifest(&self) -> Result<DeploymentBackupManifest, PersistenceError> {
89        let manifest = read_json(&self.manifest_path())?;
90        DeploymentBackupManifest::validate(&manifest)?;
91        Ok(manifest)
92    }
93
94    /// Write a validated backup plan with atomic replace semantics.
95    pub fn write_backup_plan(&self, plan: &BackupPlan) -> Result<(), PersistenceError> {
96        plan.validate()?;
97        write_json_atomic(&self.backup_plan_path(), plan)
98    }
99
100    /// Read and validate a backup plan from this layout.
101    pub fn read_backup_plan(&self) -> Result<BackupPlan, PersistenceError> {
102        let plan = read_json(&self.backup_plan_path())?;
103        BackupPlan::validate(&plan)?;
104        Ok(plan)
105    }
106
107    /// Write a validated download journal with atomic replace semantics.
108    pub fn write_journal(&self, journal: &DownloadJournal) -> Result<(), PersistenceError> {
109        journal.validate()?;
110        write_json_atomic(&self.journal_path(), journal)
111    }
112
113    /// Read and validate a download journal from this backup layout.
114    pub fn read_journal(&self) -> Result<DownloadJournal, PersistenceError> {
115        let journal = read_json(&self.journal_path())?;
116        DownloadJournal::validate(&journal)?;
117        Ok(journal)
118    }
119
120    /// Write a validated backup execution journal with atomic replace semantics.
121    pub fn write_execution_journal(
122        &self,
123        journal: &BackupExecutionJournal,
124    ) -> Result<(), PersistenceError> {
125        journal.validate()?;
126        write_json_atomic(&self.execution_journal_path(), journal)
127    }
128
129    /// Read and validate a backup execution journal from this layout.
130    pub fn read_execution_journal(&self) -> Result<BackupExecutionJournal, PersistenceError> {
131        let journal = read_json(&self.execution_journal_path())?;
132        BackupExecutionJournal::validate(&journal)?;
133        Ok(journal)
134    }
135
136    /// Validate the manifest, journal, and durable artifact checksums.
137    pub fn verify_integrity(&self) -> Result<BackupIntegrityReport, PersistenceError> {
138        let manifest = self.read_manifest()?;
139        let journal = self.read_journal()?;
140        verify_layout_integrity(self, &manifest, &journal)
141    }
142
143    /// Validate the persisted backup plan and execution journal agree.
144    pub fn verify_execution_integrity(
145        &self,
146    ) -> Result<BackupExecutionIntegrityReport, PersistenceError> {
147        let plan = self.read_backup_plan()?;
148        let journal = self.read_execution_journal()?;
149        verify_execution_integrity(&plan, &journal)
150    }
151}
152
153///
154/// PersistenceError
155///
156
157#[derive(Debug, ThisError)]
158pub enum PersistenceError {
159    #[error(transparent)]
160    Io(#[from] io::Error),
161
162    #[error(transparent)]
163    Json(#[from] serde_json::Error),
164
165    #[error(transparent)]
166    InvalidManifest(#[from] ManifestValidationError),
167
168    #[error(transparent)]
169    InvalidJournal(#[from] crate::journal::JournalValidationError),
170
171    #[error(transparent)]
172    InvalidBackupPlan(#[from] BackupPlanError),
173
174    #[error(transparent)]
175    InvalidExecutionJournal(#[from] BackupExecutionJournalError),
176
177    #[error(transparent)]
178    Checksum(#[from] ArtifactChecksumError),
179
180    #[error("manifest backup id {manifest} does not match journal backup id {journal}")]
181    BackupIdMismatch { manifest: String, journal: String },
182
183    #[error("journal artifact {canister_id} snapshot {snapshot_id} is not durable")]
184    NonDurableArtifact {
185        canister_id: String,
186        snapshot_id: String,
187    },
188
189    #[error("journal artifact {canister_id} snapshot {snapshot_id} has no checksum")]
190    MissingJournalArtifactChecksum {
191        canister_id: String,
192        snapshot_id: String,
193    },
194
195    #[error("manifest member {canister_id} snapshot {snapshot_id} has no journal artifact")]
196    MissingJournalArtifact {
197        canister_id: String,
198        snapshot_id: String,
199    },
200
201    #[error("journal artifact {canister_id} snapshot {snapshot_id} is not declared in manifest")]
202    UnexpectedJournalArtifact {
203        canister_id: String,
204        snapshot_id: String,
205    },
206
207    #[error(
208        "manifest checksum for {canister_id} snapshot {snapshot_id} does not match journal checksum"
209    )]
210    ManifestJournalChecksumMismatch {
211        canister_id: String,
212        snapshot_id: String,
213        manifest: String,
214        journal: String,
215    },
216
217    #[error(
218        "manifest artifact path for {canister_id} snapshot {snapshot_id} does not match journal artifact path"
219    )]
220    ManifestJournalArtifactPathMismatch {
221        canister_id: String,
222        snapshot_id: String,
223        manifest: String,
224        journal: String,
225    },
226
227    #[error("manifest topology receipt {field} does not match journal topology receipt")]
228    ManifestJournalTopologyReceiptMismatch {
229        field: String,
230        manifest: String,
231        journal: Option<String>,
232    },
233
234    #[error("backup plan {field} does not match execution journal")]
235    PlanJournalMismatch {
236        field: &'static str,
237        plan: String,
238        journal: String,
239    },
240
241    #[error("backup plan operation {sequence} {field} does not match execution journal")]
242    PlanJournalOperationMismatch {
243        sequence: usize,
244        field: &'static str,
245        plan: String,
246        journal: String,
247    },
248
249    #[error("backup execution operation {sequence} is {state} but has no matching receipt")]
250    ExecutionOperationMissingReceipt { sequence: usize, state: String },
251
252    #[error("backup execution operation {sequence} timestamp does not match latest receipt")]
253    ExecutionOperationReceiptTimestampMismatch { sequence: usize },
254
255    #[error("artifact path escapes backup root: {artifact_path}")]
256    ArtifactPathEscapesBackup { artifact_path: String },
257
258    #[error("artifact path does not exist: {0}")]
259    MissingArtifact(String),
260}
261
262// Write JSON to a temporary sibling path and then atomically replace the target.
263fn write_json_atomic<T>(path: &Path, value: &T) -> Result<(), PersistenceError>
264where
265    T: Serialize,
266{
267    if let Some(parent) = path.parent() {
268        fs::create_dir_all(parent)?;
269    }
270
271    let tmp_path = temp_path_for(path);
272    let mut file = File::create(&tmp_path)?;
273    serde_json::to_writer_pretty(&mut file, value)?;
274    file.sync_all()?;
275    drop(file);
276
277    fs::rename(&tmp_path, path)?;
278
279    if let Some(parent) = path.parent() {
280        File::open(parent)?.sync_all()?;
281    }
282
283    Ok(())
284}
285
286// Read one JSON document from disk.
287fn read_json<T>(path: &Path) -> Result<T, PersistenceError>
288where
289    T: DeserializeOwned,
290{
291    let file = File::open(path)?;
292    Ok(serde_json::from_reader(file)?)
293}
294
295// Build the sibling temporary path used for atomic writes.
296fn temp_path_for(path: &Path) -> PathBuf {
297    let mut file_name = path
298        .file_name()
299        .and_then(|name| name.to_str())
300        .unwrap_or("canic-backup")
301        .to_string();
302    file_name.push_str(".tmp");
303    path.with_file_name(file_name)
304}
305
306#[cfg(test)]
307mod tests;