canic_backup/persistence/
mod.rs1use 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#[derive(Clone, Debug)]
23pub struct BackupLayout {
24 root: PathBuf,
25}
26
27impl BackupLayout {
28 #[must_use]
30 pub const fn new(root: PathBuf) -> Self {
31 Self { root }
32 }
33
34 #[must_use]
36 pub fn root(&self) -> &Path {
37 &self.root
38 }
39
40 #[must_use]
42 pub fn manifest_path(&self) -> PathBuf {
43 self.root.join(MANIFEST_FILE_NAME)
44 }
45
46 #[must_use]
48 pub fn journal_path(&self) -> PathBuf {
49 self.root.join(JOURNAL_FILE_NAME)
50 }
51
52 pub fn write_manifest(&self, manifest: &FleetBackupManifest) -> Result<(), PersistenceError> {
54 manifest.validate()?;
55 write_json_atomic(&self.manifest_path(), manifest)
56 }
57
58 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 pub fn write_journal(&self, journal: &DownloadJournal) -> Result<(), PersistenceError> {
67 journal.validate()?;
68 write_json_atomic(&self.journal_path(), journal)
69 }
70
71 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 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
91struct TopologyReceiptMismatch {
92 field: String,
93 manifest: String,
94 journal: Option<String>,
95}
96
97#[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#[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#[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
199fn 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
312fn 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
333fn 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#[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
368fn 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
392fn 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
401fn 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;