1use crate::{
2 artifacts::{ArtifactChecksum, ArtifactChecksumError},
3 journal::{ArtifactState, DownloadJournal},
4 manifest::{BackupUnitKind, ConsistencyMode, FleetBackupManifest, ManifestValidationError},
5};
6use serde::{Deserialize, Serialize, de::DeserializeOwned};
7use std::{
8 collections::{BTreeMap, BTreeSet},
9 fs::{self, File},
10 io,
11 path::{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 pub fn inspect(&self) -> Result<BackupInspectionReport, PersistenceError> {
87 let manifest = self.read_manifest()?;
88 let journal = self.read_journal()?;
89 Ok(inspect_layout(&manifest, &journal))
90 }
91
92 pub fn provenance(&self) -> Result<BackupProvenanceReport, PersistenceError> {
94 let manifest = self.read_manifest()?;
95 let journal = self.read_journal()?;
96 Ok(provenance_report(&manifest, &journal))
97 }
98}
99
100#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
105pub struct BackupProvenanceReport {
106 pub backup_id: String,
107 pub manifest_backup_id: String,
108 pub journal_backup_id: String,
109 pub backup_id_matches: bool,
110 pub manifest_version: u16,
111 pub journal_version: u16,
112 pub created_at: String,
113 pub tool_name: String,
114 pub tool_version: String,
115 pub source_environment: String,
116 pub source_root_canister: String,
117 pub topology_hash_algorithm: String,
118 pub topology_hash_input: String,
119 pub discovery_topology_hash: String,
120 pub pre_snapshot_topology_hash: String,
121 pub accepted_topology_hash: String,
122 pub journal_discovery_topology_hash: Option<String>,
123 pub journal_pre_snapshot_topology_hash: Option<String>,
124 pub topology_receipts_match: bool,
125 pub topology_receipt_mismatches: Vec<TopologyReceiptMismatch>,
126 pub backup_unit_count: usize,
127 pub member_count: usize,
128 pub consistency_mode: String,
129 pub backup_units: Vec<BackupUnitProvenance>,
130 pub members: Vec<MemberSnapshotProvenance>,
131}
132
133#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
138pub struct BackupUnitProvenance {
139 pub unit_id: String,
140 pub kind: String,
141 pub roles: Vec<String>,
142 pub consistency_reason: Option<String>,
143 pub dependency_closure: Vec<String>,
144 pub topology_validation: String,
145 pub quiescence_strategy: Option<String>,
146}
147
148#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
153pub struct MemberSnapshotProvenance {
154 pub canister_id: String,
155 pub role: String,
156 pub parent_canister_id: Option<String>,
157 pub subnet_canister_id: Option<String>,
158 pub identity_mode: String,
159 pub restore_group: u16,
160 pub verification_class: String,
161 pub verification_checks: usize,
162 pub snapshot_id: String,
163 pub module_hash: Option<String>,
164 pub wasm_hash: Option<String>,
165 pub code_version: Option<String>,
166 pub artifact_path: String,
167 pub checksum_algorithm: String,
168 pub manifest_checksum: Option<String>,
169 pub journal_state: Option<String>,
170 pub journal_checksum: Option<String>,
171 pub journal_updated_at: Option<String>,
172}
173
174#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
179pub struct BackupInspectionReport {
180 pub backup_id: String,
181 pub manifest_backup_id: String,
182 pub journal_backup_id: String,
183 pub backup_id_matches: bool,
184 pub journal_complete: bool,
185 pub ready_for_verify: bool,
186 pub manifest_members: usize,
187 pub journal_artifacts: usize,
188 pub matched_artifacts: usize,
189 pub topology_receipt_mismatches: Vec<TopologyReceiptMismatch>,
190 pub missing_journal_artifacts: Vec<ArtifactReference>,
191 pub unexpected_journal_artifacts: Vec<ArtifactReference>,
192 pub path_mismatches: Vec<ArtifactPathMismatch>,
193 pub checksum_mismatches: Vec<ArtifactChecksumMismatch>,
194}
195
196#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
201pub struct TopologyReceiptMismatch {
202 pub field: String,
203 pub manifest: String,
204 pub journal: Option<String>,
205}
206
207#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
212pub struct ArtifactReference {
213 pub canister_id: String,
214 pub snapshot_id: String,
215}
216
217#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
222pub struct ArtifactPathMismatch {
223 pub canister_id: String,
224 pub snapshot_id: String,
225 pub manifest: String,
226 pub journal: String,
227}
228
229#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
234pub struct ArtifactChecksumMismatch {
235 pub canister_id: String,
236 pub snapshot_id: String,
237 pub manifest: String,
238 pub journal: String,
239}
240
241#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
246pub struct BackupIntegrityReport {
247 pub backup_id: String,
248 pub verified: bool,
249 pub manifest_members: usize,
250 pub journal_artifacts: usize,
251 pub durable_artifacts: usize,
252 pub artifacts: Vec<ArtifactIntegrityReport>,
253}
254
255#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
260pub struct ArtifactIntegrityReport {
261 pub canister_id: String,
262 pub snapshot_id: String,
263 pub artifact_path: String,
264 pub checksum: String,
265}
266
267#[derive(Debug, ThisError)]
272pub enum PersistenceError {
273 #[error(transparent)]
274 Io(#[from] io::Error),
275
276 #[error(transparent)]
277 Json(#[from] serde_json::Error),
278
279 #[error(transparent)]
280 InvalidManifest(#[from] ManifestValidationError),
281
282 #[error(transparent)]
283 InvalidJournal(#[from] crate::journal::JournalValidationError),
284
285 #[error(transparent)]
286 Checksum(#[from] ArtifactChecksumError),
287
288 #[error("manifest backup id {manifest} does not match journal backup id {journal}")]
289 BackupIdMismatch { manifest: String, journal: String },
290
291 #[error("journal artifact {canister_id} snapshot {snapshot_id} is not durable")]
292 NonDurableArtifact {
293 canister_id: String,
294 snapshot_id: String,
295 },
296
297 #[error("manifest member {canister_id} snapshot {snapshot_id} has no journal artifact")]
298 MissingJournalArtifact {
299 canister_id: String,
300 snapshot_id: String,
301 },
302
303 #[error("journal artifact {canister_id} snapshot {snapshot_id} is not declared in manifest")]
304 UnexpectedJournalArtifact {
305 canister_id: String,
306 snapshot_id: String,
307 },
308
309 #[error(
310 "manifest checksum for {canister_id} snapshot {snapshot_id} does not match journal checksum"
311 )]
312 ManifestJournalChecksumMismatch {
313 canister_id: String,
314 snapshot_id: String,
315 manifest: String,
316 journal: String,
317 },
318
319 #[error(
320 "manifest artifact path for {canister_id} snapshot {snapshot_id} does not match journal artifact path"
321 )]
322 ManifestJournalArtifactPathMismatch {
323 canister_id: String,
324 snapshot_id: String,
325 manifest: String,
326 journal: String,
327 },
328
329 #[error("manifest topology receipt {field} does not match journal topology receipt")]
330 ManifestJournalTopologyReceiptMismatch {
331 field: String,
332 manifest: String,
333 journal: Option<String>,
334 },
335
336 #[error("artifact path does not exist: {0}")]
337 MissingArtifact(String),
338}
339
340fn inspect_layout(
342 manifest: &FleetBackupManifest,
343 journal: &DownloadJournal,
344) -> BackupInspectionReport {
345 let journal_report = journal.resume_report();
346 let journal_artifacts = journal
347 .artifacts
348 .iter()
349 .map(|entry| (artifact_key(&entry.canister_id, &entry.snapshot_id), entry))
350 .collect::<BTreeMap<_, _>>();
351 let manifest_artifacts = manifest
352 .fleet
353 .members
354 .iter()
355 .map(|member| {
356 (
357 artifact_key(&member.canister_id, &member.source_snapshot.snapshot_id),
358 member,
359 )
360 })
361 .collect::<BTreeMap<_, _>>();
362
363 let mut matched_artifacts = 0;
364 let mut missing_journal_artifacts = Vec::new();
365 let mut path_mismatches = Vec::new();
366 let mut checksum_mismatches = Vec::new();
367
368 for (key, member) in &manifest_artifacts {
369 let Some(entry) = journal_artifacts.get(key) else {
370 missing_journal_artifacts.push(artifact_reference(key));
371 continue;
372 };
373
374 matched_artifacts += 1;
375 if member.source_snapshot.artifact_path != entry.artifact_path {
376 path_mismatches.push(ArtifactPathMismatch {
377 canister_id: key.0.clone(),
378 snapshot_id: key.1.clone(),
379 manifest: member.source_snapshot.artifact_path.clone(),
380 journal: entry.artifact_path.clone(),
381 });
382 }
383
384 if let (Some(manifest_hash), Some(journal_hash)) = (
385 member.source_snapshot.checksum.as_deref(),
386 entry.checksum.as_deref(),
387 ) && manifest_hash != journal_hash
388 {
389 checksum_mismatches.push(ArtifactChecksumMismatch {
390 canister_id: key.0.clone(),
391 snapshot_id: key.1.clone(),
392 manifest: manifest_hash.to_string(),
393 journal: journal_hash.to_string(),
394 });
395 }
396 }
397
398 let unexpected_journal_artifacts = journal_artifacts
399 .keys()
400 .filter(|key| !manifest_artifacts.contains_key(*key))
401 .map(artifact_reference)
402 .collect::<Vec<_>>();
403 let topology_receipt_mismatches = topology_receipt_mismatches(manifest, journal);
404 let topology_receipts_match = topology_receipt_mismatches.is_empty();
405 let backup_id_matches = manifest.backup_id == journal.backup_id;
406 let ready_for_verify = backup_id_matches
407 && topology_receipts_match
408 && journal_report.is_complete
409 && missing_journal_artifacts.is_empty()
410 && unexpected_journal_artifacts.is_empty()
411 && path_mismatches.is_empty()
412 && checksum_mismatches.is_empty();
413
414 BackupInspectionReport {
415 backup_id: manifest.backup_id.clone(),
416 manifest_backup_id: manifest.backup_id.clone(),
417 journal_backup_id: journal.backup_id.clone(),
418 backup_id_matches,
419 journal_complete: journal_report.is_complete,
420 ready_for_verify,
421 manifest_members: manifest.fleet.members.len(),
422 journal_artifacts: journal.artifacts.len(),
423 matched_artifacts,
424 topology_receipt_mismatches,
425 missing_journal_artifacts,
426 unexpected_journal_artifacts,
427 path_mismatches,
428 checksum_mismatches,
429 }
430}
431
432fn provenance_report(
434 manifest: &FleetBackupManifest,
435 journal: &DownloadJournal,
436) -> BackupProvenanceReport {
437 let journal_artifacts = journal
438 .artifacts
439 .iter()
440 .map(|entry| (artifact_key(&entry.canister_id, &entry.snapshot_id), entry))
441 .collect::<BTreeMap<_, _>>();
442 let topology_receipt_mismatches = topology_receipt_mismatches(manifest, journal);
443 let topology_receipts_match = topology_receipt_mismatches.is_empty();
444
445 BackupProvenanceReport {
446 backup_id: manifest.backup_id.clone(),
447 manifest_backup_id: manifest.backup_id.clone(),
448 journal_backup_id: journal.backup_id.clone(),
449 backup_id_matches: manifest.backup_id == journal.backup_id,
450 manifest_version: manifest.manifest_version,
451 journal_version: journal.journal_version,
452 created_at: manifest.created_at.clone(),
453 tool_name: manifest.tool.name.clone(),
454 tool_version: manifest.tool.version.clone(),
455 source_environment: manifest.source.environment.clone(),
456 source_root_canister: manifest.source.root_canister.clone(),
457 topology_hash_algorithm: manifest.fleet.topology_hash_algorithm.clone(),
458 topology_hash_input: manifest.fleet.topology_hash_input.clone(),
459 discovery_topology_hash: manifest.fleet.discovery_topology_hash.clone(),
460 pre_snapshot_topology_hash: manifest.fleet.pre_snapshot_topology_hash.clone(),
461 accepted_topology_hash: manifest.fleet.topology_hash.clone(),
462 journal_discovery_topology_hash: journal.discovery_topology_hash.clone(),
463 journal_pre_snapshot_topology_hash: journal.pre_snapshot_topology_hash.clone(),
464 topology_receipts_match,
465 topology_receipt_mismatches,
466 backup_unit_count: manifest.consistency.backup_units.len(),
467 member_count: manifest.fleet.members.len(),
468 consistency_mode: consistency_mode_name(&manifest.consistency.mode).to_string(),
469 backup_units: manifest
470 .consistency
471 .backup_units
472 .iter()
473 .map(|unit| BackupUnitProvenance {
474 unit_id: unit.unit_id.clone(),
475 kind: backup_unit_kind_name(&unit.kind).to_string(),
476 roles: unit.roles.clone(),
477 consistency_reason: unit.consistency_reason.clone(),
478 dependency_closure: unit.dependency_closure.clone(),
479 topology_validation: unit.topology_validation.clone(),
480 quiescence_strategy: unit.quiescence_strategy.clone(),
481 })
482 .collect(),
483 members: manifest
484 .fleet
485 .members
486 .iter()
487 .map(|member| {
488 let journal_entry = journal_artifacts.get(&artifact_key(
489 &member.canister_id,
490 &member.source_snapshot.snapshot_id,
491 ));
492
493 MemberSnapshotProvenance {
494 canister_id: member.canister_id.clone(),
495 role: member.role.clone(),
496 parent_canister_id: member.parent_canister_id.clone(),
497 subnet_canister_id: member.subnet_canister_id.clone(),
498 identity_mode: identity_mode_name(&member.identity_mode).to_string(),
499 restore_group: member.restore_group,
500 verification_class: member.verification_class.clone(),
501 verification_checks: member.verification_checks.len(),
502 snapshot_id: member.source_snapshot.snapshot_id.clone(),
503 module_hash: member.source_snapshot.module_hash.clone(),
504 wasm_hash: member.source_snapshot.wasm_hash.clone(),
505 code_version: member.source_snapshot.code_version.clone(),
506 artifact_path: member.source_snapshot.artifact_path.clone(),
507 checksum_algorithm: member.source_snapshot.checksum_algorithm.clone(),
508 manifest_checksum: member.source_snapshot.checksum.clone(),
509 journal_state: journal_entry
510 .map(|entry| artifact_state_name(entry.state).to_string()),
511 journal_checksum: journal_entry.and_then(|entry| entry.checksum.clone()),
512 journal_updated_at: journal_entry.map(|entry| entry.updated_at.clone()),
513 }
514 })
515 .collect(),
516 }
517}
518
519fn verify_layout_integrity(
521 layout: &BackupLayout,
522 manifest: &FleetBackupManifest,
523 journal: &DownloadJournal,
524) -> Result<BackupIntegrityReport, PersistenceError> {
525 if manifest.backup_id != journal.backup_id {
526 return Err(PersistenceError::BackupIdMismatch {
527 manifest: manifest.backup_id.clone(),
528 journal: journal.backup_id.clone(),
529 });
530 }
531
532 if let Some(mismatch) = topology_receipt_mismatches(manifest, journal)
533 .into_iter()
534 .next()
535 {
536 return Err(PersistenceError::ManifestJournalTopologyReceiptMismatch {
537 field: mismatch.field,
538 manifest: mismatch.manifest,
539 journal: mismatch.journal,
540 });
541 }
542
543 let expected_artifacts = manifest
544 .fleet
545 .members
546 .iter()
547 .map(|member| {
548 (
549 member.canister_id.as_str(),
550 member.source_snapshot.snapshot_id.as_str(),
551 )
552 })
553 .collect::<BTreeSet<_>>();
554 for entry in &journal.artifacts {
555 if !expected_artifacts.contains(&(entry.canister_id.as_str(), entry.snapshot_id.as_str())) {
556 return Err(PersistenceError::UnexpectedJournalArtifact {
557 canister_id: entry.canister_id.clone(),
558 snapshot_id: entry.snapshot_id.clone(),
559 });
560 }
561 }
562
563 let mut artifacts = Vec::with_capacity(journal.artifacts.len());
564 for member in &manifest.fleet.members {
565 let Some(entry) = journal.artifacts.iter().find(|entry| {
566 entry.canister_id == member.canister_id
567 && entry.snapshot_id == member.source_snapshot.snapshot_id
568 }) else {
569 return Err(PersistenceError::MissingJournalArtifact {
570 canister_id: member.canister_id.clone(),
571 snapshot_id: member.source_snapshot.snapshot_id.clone(),
572 });
573 };
574
575 if entry.state != ArtifactState::Durable {
576 return Err(PersistenceError::NonDurableArtifact {
577 canister_id: entry.canister_id.clone(),
578 snapshot_id: entry.snapshot_id.clone(),
579 });
580 }
581
582 let Some(expected_hash) = entry.checksum.as_deref() else {
583 unreachable!("validated durable journals must include checksums");
584 };
585 if member.source_snapshot.artifact_path != entry.artifact_path {
586 return Err(PersistenceError::ManifestJournalArtifactPathMismatch {
587 canister_id: entry.canister_id.clone(),
588 snapshot_id: entry.snapshot_id.clone(),
589 manifest: member.source_snapshot.artifact_path.clone(),
590 journal: entry.artifact_path.clone(),
591 });
592 }
593 if let Some(manifest_hash) = member.source_snapshot.checksum.as_deref()
594 && manifest_hash != expected_hash
595 {
596 return Err(PersistenceError::ManifestJournalChecksumMismatch {
597 canister_id: entry.canister_id.clone(),
598 snapshot_id: entry.snapshot_id.clone(),
599 manifest: manifest_hash.to_string(),
600 journal: expected_hash.to_string(),
601 });
602 }
603 let artifact_path = resolve_artifact_path(layout.root(), &entry.artifact_path);
604 if !artifact_path.exists() {
605 return Err(PersistenceError::MissingArtifact(
606 artifact_path.display().to_string(),
607 ));
608 }
609
610 ArtifactChecksum::from_path(&artifact_path)?.verify(expected_hash)?;
611 artifacts.push(ArtifactIntegrityReport {
612 canister_id: entry.canister_id.clone(),
613 snapshot_id: entry.snapshot_id.clone(),
614 artifact_path: artifact_path.display().to_string(),
615 checksum: expected_hash.to_string(),
616 });
617 }
618
619 Ok(BackupIntegrityReport {
620 backup_id: manifest.backup_id.clone(),
621 verified: true,
622 manifest_members: manifest.fleet.members.len(),
623 journal_artifacts: journal.artifacts.len(),
624 durable_artifacts: artifacts.len(),
625 artifacts,
626 })
627}
628
629fn artifact_key(canister_id: &str, snapshot_id: &str) -> (String, String) {
631 (canister_id.to_string(), snapshot_id.to_string())
632}
633
634fn artifact_reference(key: &(String, String)) -> ArtifactReference {
636 ArtifactReference {
637 canister_id: key.0.clone(),
638 snapshot_id: key.1.clone(),
639 }
640}
641
642fn topology_receipt_mismatches(
644 manifest: &FleetBackupManifest,
645 journal: &DownloadJournal,
646) -> Vec<TopologyReceiptMismatch> {
647 let mut mismatches = Vec::new();
648 record_topology_receipt_mismatch(
649 &mut mismatches,
650 "discovery_topology_hash",
651 &manifest.fleet.discovery_topology_hash,
652 journal.discovery_topology_hash.as_deref(),
653 );
654 record_topology_receipt_mismatch(
655 &mut mismatches,
656 "pre_snapshot_topology_hash",
657 &manifest.fleet.pre_snapshot_topology_hash,
658 journal.pre_snapshot_topology_hash.as_deref(),
659 );
660 mismatches
661}
662
663fn record_topology_receipt_mismatch(
665 mismatches: &mut Vec<TopologyReceiptMismatch>,
666 field: &str,
667 manifest: &str,
668 journal: Option<&str>,
669) {
670 if journal == Some(manifest) {
671 return;
672 }
673
674 mismatches.push(TopologyReceiptMismatch {
675 field: field.to_string(),
676 manifest: manifest.to_string(),
677 journal: journal.map(ToString::to_string),
678 });
679}
680
681const fn consistency_mode_name(mode: &ConsistencyMode) -> &'static str {
683 match mode {
684 ConsistencyMode::CrashConsistent => "crash-consistent",
685 ConsistencyMode::QuiescedUnit => "quiesced-unit",
686 }
687}
688
689const fn backup_unit_kind_name(kind: &BackupUnitKind) -> &'static str {
691 match kind {
692 BackupUnitKind::WholeFleet => "whole-fleet",
693 BackupUnitKind::ControlPlaneSubset => "control-plane-subset",
694 BackupUnitKind::SubtreeRooted => "subtree-rooted",
695 BackupUnitKind::Flat => "flat",
696 }
697}
698
699const fn identity_mode_name(mode: &crate::manifest::IdentityMode) -> &'static str {
701 match mode {
702 crate::manifest::IdentityMode::Fixed => "fixed",
703 crate::manifest::IdentityMode::Relocatable => "relocatable",
704 }
705}
706
707const fn artifact_state_name(state: ArtifactState) -> &'static str {
709 match state {
710 ArtifactState::Created => "Created",
711 ArtifactState::Downloaded => "Downloaded",
712 ArtifactState::ChecksumVerified => "ChecksumVerified",
713 ArtifactState::Durable => "Durable",
714 }
715}
716
717fn resolve_artifact_path(root: &Path, artifact_path: &str) -> PathBuf {
719 let path = PathBuf::from(artifact_path);
720 if path.is_absolute() || path.exists() {
721 path
722 } else {
723 root.join(path)
724 }
725}
726
727fn write_json_atomic<T>(path: &Path, value: &T) -> Result<(), PersistenceError>
729where
730 T: Serialize,
731{
732 if let Some(parent) = path.parent() {
733 fs::create_dir_all(parent)?;
734 }
735
736 let tmp_path = temp_path_for(path);
737 let mut file = File::create(&tmp_path)?;
738 serde_json::to_writer_pretty(&mut file, value)?;
739 file.sync_all()?;
740 drop(file);
741
742 fs::rename(&tmp_path, path)?;
743
744 if let Some(parent) = path.parent() {
745 File::open(parent)?.sync_all()?;
746 }
747
748 Ok(())
749}
750
751fn read_json<T>(path: &Path) -> Result<T, PersistenceError>
753where
754 T: DeserializeOwned,
755{
756 let file = File::open(path)?;
757 Ok(serde_json::from_reader(file)?)
758}
759
760fn temp_path_for(path: &Path) -> PathBuf {
762 let mut file_name = path
763 .file_name()
764 .and_then(|name| name.to_str())
765 .unwrap_or("canic-backup")
766 .to_string();
767 file_name.push_str(".tmp");
768 path.with_file_name(file_name)
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774 use crate::{
775 journal::{ArtifactJournalEntry, ArtifactState},
776 manifest::{
777 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember,
778 FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
779 VerificationCheck, VerificationPlan,
780 },
781 };
782 use std::{
783 fs,
784 time::{SystemTime, UNIX_EPOCH},
785 };
786
787 const ROOT: &str = "aaaaa-aa";
788 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
789 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
790
791 #[test]
793 fn manifest_round_trips_through_layout() {
794 let root = temp_dir("canic-backup-manifest-layout");
795 let layout = BackupLayout::new(root.clone());
796 let manifest = valid_manifest();
797
798 layout
799 .write_manifest(&manifest)
800 .expect("write manifest atomically");
801 let read = layout.read_manifest().expect("read manifest");
802
803 fs::remove_dir_all(root).expect("remove temp layout");
804 assert_eq!(read.backup_id, manifest.backup_id);
805 }
806
807 #[test]
809 fn journal_round_trips_through_layout() {
810 let root = temp_dir("canic-backup-journal-layout");
811 let layout = BackupLayout::new(root.clone());
812 let journal = valid_journal();
813
814 layout
815 .write_journal(&journal)
816 .expect("write journal atomically");
817 let read = layout.read_journal().expect("read journal");
818
819 fs::remove_dir_all(root).expect("remove temp layout");
820 assert_eq!(read.backup_id, journal.backup_id);
821 }
822
823 #[test]
825 fn invalid_manifest_is_not_written() {
826 let root = temp_dir("canic-backup-invalid-manifest");
827 let layout = BackupLayout::new(root.clone());
828 let mut manifest = valid_manifest();
829 manifest.fleet.discovery_topology_hash = "bad".to_string();
830
831 let err = layout
832 .write_manifest(&manifest)
833 .expect_err("invalid manifest should fail");
834
835 let manifest_path = layout.manifest_path();
836 fs::remove_dir_all(root).ok();
837 assert!(matches!(err, PersistenceError::InvalidManifest(_)));
838 assert!(!manifest_path.exists());
839 }
840
841 #[test]
843 fn inspect_reports_ready_layout_metadata() {
844 let root = temp_dir("canic-backup-inspect-ready");
845 let layout = BackupLayout::new(root.clone());
846
847 layout
848 .write_manifest(&valid_manifest())
849 .expect("write manifest");
850 layout
851 .write_journal(&valid_journal())
852 .expect("write journal");
853
854 let report = layout.inspect().expect("inspect layout");
855
856 fs::remove_dir_all(root).expect("remove temp layout");
857 assert_eq!(report.backup_id, "fbk_test_001");
858 assert!(report.backup_id_matches);
859 assert!(report.journal_complete);
860 assert!(report.ready_for_verify);
861 assert_eq!(report.manifest_members, 1);
862 assert_eq!(report.journal_artifacts, 1);
863 assert_eq!(report.matched_artifacts, 1);
864 assert!(report.topology_receipt_mismatches.is_empty());
865 assert!(report.missing_journal_artifacts.is_empty());
866 assert!(report.unexpected_journal_artifacts.is_empty());
867 assert!(report.path_mismatches.is_empty());
868 assert!(report.checksum_mismatches.is_empty());
869 }
870
871 #[test]
873 fn inspect_reports_manifest_journal_provenance_drift() {
874 let root = temp_dir("canic-backup-inspect-drift");
875 let layout = BackupLayout::new(root.clone());
876 let mut manifest = valid_manifest();
877 manifest.fleet.members[0].source_snapshot.artifact_path =
878 "artifacts/manifest-root".to_string();
879 manifest.fleet.members[0].source_snapshot.checksum = Some(HASH.to_string());
880 let mut journal = journal_with_checksum(
881 "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string(),
882 );
883 journal.artifacts[0].artifact_path = "artifacts/journal-root".to_string();
884 journal.pre_snapshot_topology_hash =
885 Some("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string());
886
887 layout.write_manifest(&manifest).expect("write manifest");
888 layout.write_journal(&journal).expect("write journal");
889
890 let report = layout.inspect().expect("inspect layout");
891
892 fs::remove_dir_all(root).expect("remove temp layout");
893 assert!(!report.ready_for_verify);
894 assert_eq!(report.matched_artifacts, 1);
895 assert_eq!(report.topology_receipt_mismatches.len(), 1);
896 assert_eq!(report.path_mismatches.len(), 1);
897 assert_eq!(report.checksum_mismatches.len(), 1);
898 }
899
900 #[test]
902 fn inspect_reports_missing_and_unexpected_artifacts() {
903 let root = temp_dir("canic-backup-inspect-boundary");
904 let layout = BackupLayout::new(root.clone());
905 let mut journal = valid_journal();
906 journal.artifacts[0].snapshot_id = "other-snapshot".to_string();
907
908 layout
909 .write_manifest(&valid_manifest())
910 .expect("write manifest");
911 layout.write_journal(&journal).expect("write journal");
912
913 let report = layout.inspect().expect("inspect layout");
914
915 fs::remove_dir_all(root).expect("remove temp layout");
916 assert!(!report.ready_for_verify);
917 assert_eq!(report.matched_artifacts, 0);
918 assert_eq!(report.missing_journal_artifacts.len(), 1);
919 assert_eq!(report.unexpected_journal_artifacts.len(), 1);
920 }
921
922 #[test]
924 fn provenance_reports_manifest_and_journal_receipts() {
925 let root = temp_dir("canic-backup-provenance");
926 let layout = BackupLayout::new(root.clone());
927
928 layout
929 .write_manifest(&valid_manifest())
930 .expect("write manifest");
931 layout
932 .write_journal(&valid_journal())
933 .expect("write journal");
934
935 let report = layout.provenance().expect("read provenance");
936
937 fs::remove_dir_all(root).expect("remove temp layout");
938 assert_eq!(report.backup_id, "fbk_test_001");
939 assert_eq!(report.manifest_backup_id, "fbk_test_001");
940 assert_eq!(report.journal_backup_id, "fbk_test_001");
941 assert!(report.backup_id_matches);
942 assert_eq!(report.source_environment, "local");
943 assert_eq!(report.source_root_canister, ROOT);
944 assert_eq!(report.discovery_topology_hash, HASH);
945 assert_eq!(
946 report.journal_discovery_topology_hash,
947 Some(HASH.to_string())
948 );
949 assert!(report.topology_receipts_match);
950 assert!(report.topology_receipt_mismatches.is_empty());
951 assert_eq!(report.backup_unit_count, 1);
952 assert_eq!(report.member_count, 1);
953 assert_eq!(report.consistency_mode, "crash-consistent");
954 assert_eq!(report.backup_units[0].kind, "whole-fleet");
955 assert_eq!(report.members[0].canister_id, ROOT);
956 assert_eq!(report.members[0].identity_mode, "fixed");
957 assert_eq!(report.members[0].module_hash, Some(HASH.to_string()));
958 assert_eq!(report.members[0].wasm_hash, Some(HASH.to_string()));
959 assert_eq!(report.members[0].journal_state, Some("Durable".to_string()));
960 assert_eq!(report.members[0].journal_checksum, Some(HASH.to_string()));
961 }
962
963 #[test]
965 fn integrity_verifies_durable_artifacts() {
966 let root = temp_dir("canic-backup-integrity");
967 let layout = BackupLayout::new(root.clone());
968 let checksum = write_artifact(&root, b"root artifact");
969 let journal = journal_with_checksum(checksum.hash.clone());
970
971 layout
972 .write_manifest(&valid_manifest())
973 .expect("write manifest");
974 layout.write_journal(&journal).expect("write journal");
975
976 let report = layout.verify_integrity().expect("verify integrity");
977
978 fs::remove_dir_all(root).expect("remove temp layout");
979 assert_eq!(report.backup_id, "fbk_test_001");
980 assert!(report.verified);
981 assert_eq!(report.manifest_members, 1);
982 assert_eq!(report.durable_artifacts, 1);
983 assert_eq!(report.artifacts[0].checksum, checksum.hash);
984 }
985
986 #[test]
988 fn integrity_rejects_backup_id_mismatch() {
989 let root = temp_dir("canic-backup-integrity-id");
990 let layout = BackupLayout::new(root.clone());
991 let checksum = write_artifact(&root, b"root artifact");
992 let mut journal = journal_with_checksum(checksum.hash);
993 journal.backup_id = "other-backup".to_string();
994
995 layout
996 .write_manifest(&valid_manifest())
997 .expect("write manifest");
998 layout.write_journal(&journal).expect("write journal");
999
1000 let err = layout
1001 .verify_integrity()
1002 .expect_err("backup id mismatch should fail");
1003
1004 fs::remove_dir_all(root).expect("remove temp layout");
1005 assert!(matches!(err, PersistenceError::BackupIdMismatch { .. }));
1006 }
1007
1008 #[test]
1010 fn integrity_rejects_manifest_journal_topology_receipt_mismatch() {
1011 let root = temp_dir("canic-backup-integrity-topology");
1012 let layout = BackupLayout::new(root.clone());
1013 let checksum = write_artifact(&root, b"root artifact");
1014 let mut journal = journal_with_checksum(checksum.hash);
1015 journal.discovery_topology_hash =
1016 Some("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string());
1017
1018 layout
1019 .write_manifest(&valid_manifest())
1020 .expect("write manifest");
1021 layout.write_journal(&journal).expect("write journal");
1022
1023 let err = layout
1024 .verify_integrity()
1025 .expect_err("topology receipt mismatch should fail");
1026
1027 fs::remove_dir_all(root).expect("remove temp layout");
1028 assert!(matches!(
1029 err,
1030 PersistenceError::ManifestJournalTopologyReceiptMismatch { .. }
1031 ));
1032 }
1033
1034 #[test]
1036 fn integrity_rejects_non_durable_artifacts() {
1037 let root = temp_dir("canic-backup-integrity-state");
1038 let layout = BackupLayout::new(root.clone());
1039 let mut journal = valid_journal();
1040 journal.artifacts[0].state = ArtifactState::Created;
1041 journal.artifacts[0].checksum = None;
1042
1043 layout
1044 .write_manifest(&valid_manifest())
1045 .expect("write manifest");
1046 layout.write_journal(&journal).expect("write journal");
1047
1048 let err = layout
1049 .verify_integrity()
1050 .expect_err("non-durable artifact should fail");
1051
1052 fs::remove_dir_all(root).expect("remove temp layout");
1053 assert!(matches!(err, PersistenceError::NonDurableArtifact { .. }));
1054 }
1055
1056 #[test]
1058 fn integrity_rejects_unexpected_journal_artifacts() {
1059 let root = temp_dir("canic-backup-integrity-extra");
1060 let layout = BackupLayout::new(root.clone());
1061 let checksum = write_artifact(&root, b"root artifact");
1062 let mut journal = journal_with_checksum(checksum.hash);
1063 let mut extra = journal.artifacts[0].clone();
1064 extra.snapshot_id = "extra-snapshot".to_string();
1065 journal.artifacts.push(extra);
1066
1067 layout
1068 .write_manifest(&valid_manifest())
1069 .expect("write manifest");
1070 layout.write_journal(&journal).expect("write journal");
1071
1072 let err = layout
1073 .verify_integrity()
1074 .expect_err("unexpected journal artifact should fail");
1075
1076 fs::remove_dir_all(root).expect("remove temp layout");
1077 assert!(matches!(
1078 err,
1079 PersistenceError::UnexpectedJournalArtifact { .. }
1080 ));
1081 }
1082
1083 #[test]
1085 fn integrity_rejects_manifest_journal_checksum_mismatch() {
1086 let root = temp_dir("canic-backup-integrity-manifest-checksum");
1087 let layout = BackupLayout::new(root.clone());
1088 let checksum = write_artifact(&root, b"root artifact");
1089 let mut manifest = valid_manifest();
1090 manifest.fleet.members[0].source_snapshot.checksum =
1091 Some("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string());
1092
1093 layout.write_manifest(&manifest).expect("write manifest");
1094 layout
1095 .write_journal(&journal_with_checksum(checksum.hash))
1096 .expect("write journal");
1097
1098 let err = layout
1099 .verify_integrity()
1100 .expect_err("manifest checksum mismatch should fail");
1101
1102 fs::remove_dir_all(root).expect("remove temp layout");
1103 assert!(matches!(
1104 err,
1105 PersistenceError::ManifestJournalChecksumMismatch { .. }
1106 ));
1107 }
1108
1109 #[test]
1111 fn integrity_rejects_manifest_journal_artifact_path_mismatch() {
1112 let root = temp_dir("canic-backup-integrity-manifest-path");
1113 let layout = BackupLayout::new(root.clone());
1114 let checksum = write_artifact(&root, b"root artifact");
1115 let mut manifest = valid_manifest();
1116 manifest.fleet.members[0].source_snapshot.artifact_path =
1117 "artifacts/different-root".to_string();
1118
1119 layout.write_manifest(&manifest).expect("write manifest");
1120 layout
1121 .write_journal(&journal_with_checksum(checksum.hash))
1122 .expect("write journal");
1123
1124 let err = layout
1125 .verify_integrity()
1126 .expect_err("manifest journal artifact path mismatch should fail");
1127
1128 fs::remove_dir_all(root).expect("remove temp layout");
1129 assert!(matches!(
1130 err,
1131 PersistenceError::ManifestJournalArtifactPathMismatch { .. }
1132 ));
1133 }
1134
1135 fn valid_manifest() -> FleetBackupManifest {
1137 FleetBackupManifest {
1138 manifest_version: 1,
1139 backup_id: "fbk_test_001".to_string(),
1140 created_at: "2026-04-10T12:00:00Z".to_string(),
1141 tool: ToolMetadata {
1142 name: "canic".to_string(),
1143 version: "v1".to_string(),
1144 },
1145 source: SourceMetadata {
1146 environment: "local".to_string(),
1147 root_canister: ROOT.to_string(),
1148 },
1149 consistency: ConsistencySection {
1150 mode: ConsistencyMode::CrashConsistent,
1151 backup_units: vec![BackupUnit {
1152 unit_id: "whole-fleet".to_string(),
1153 kind: BackupUnitKind::WholeFleet,
1154 roles: vec!["root".to_string()],
1155 consistency_reason: None,
1156 dependency_closure: Vec::new(),
1157 topology_validation: "subtree-closed".to_string(),
1158 quiescence_strategy: None,
1159 }],
1160 },
1161 fleet: FleetSection {
1162 topology_hash_algorithm: "sha256".to_string(),
1163 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1164 discovery_topology_hash: HASH.to_string(),
1165 pre_snapshot_topology_hash: HASH.to_string(),
1166 topology_hash: HASH.to_string(),
1167 members: vec![FleetMember {
1168 role: "root".to_string(),
1169 canister_id: ROOT.to_string(),
1170 parent_canister_id: None,
1171 subnet_canister_id: Some(CHILD.to_string()),
1172 controller_hint: Some(ROOT.to_string()),
1173 identity_mode: IdentityMode::Fixed,
1174 restore_group: 1,
1175 verification_class: "basic".to_string(),
1176 verification_checks: vec![VerificationCheck {
1177 kind: "call".to_string(),
1178 method: Some("canic_ready".to_string()),
1179 roles: Vec::new(),
1180 }],
1181 source_snapshot: SourceSnapshot {
1182 snapshot_id: "snap-root".to_string(),
1183 module_hash: Some(HASH.to_string()),
1184 wasm_hash: Some(HASH.to_string()),
1185 code_version: Some("v0.30.0".to_string()),
1186 artifact_path: "artifacts/root".to_string(),
1187 checksum_algorithm: "sha256".to_string(),
1188 checksum: None,
1189 },
1190 }],
1191 },
1192 verification: VerificationPlan {
1193 fleet_checks: Vec::new(),
1194 member_checks: Vec::new(),
1195 },
1196 }
1197 }
1198
1199 fn valid_journal() -> DownloadJournal {
1201 journal_with_checksum(HASH.to_string())
1202 }
1203
1204 fn journal_with_checksum(checksum: String) -> DownloadJournal {
1206 DownloadJournal {
1207 journal_version: 1,
1208 backup_id: "fbk_test_001".to_string(),
1209 discovery_topology_hash: Some(HASH.to_string()),
1210 pre_snapshot_topology_hash: Some(HASH.to_string()),
1211 artifacts: vec![ArtifactJournalEntry {
1212 canister_id: ROOT.to_string(),
1213 snapshot_id: "snap-root".to_string(),
1214 state: ArtifactState::Durable,
1215 temp_path: None,
1216 artifact_path: "artifacts/root".to_string(),
1217 checksum_algorithm: "sha256".to_string(),
1218 checksum: Some(checksum),
1219 updated_at: "2026-04-10T12:00:00Z".to_string(),
1220 }],
1221 }
1222 }
1223
1224 fn write_artifact(root: &Path, bytes: &[u8]) -> ArtifactChecksum {
1226 let path = root.join("artifacts/root");
1227 fs::create_dir_all(path.parent().expect("artifact has parent")).expect("create artifacts");
1228 fs::write(&path, bytes).expect("write artifact");
1229 ArtifactChecksum::from_bytes(bytes)
1230 }
1231
1232 fn temp_dir(prefix: &str) -> PathBuf {
1234 let nanos = SystemTime::now()
1235 .duration_since(UNIX_EPOCH)
1236 .expect("system time after epoch")
1237 .as_nanos();
1238 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
1239 }
1240}