Skip to main content

canic_backup/persistence/
mod.rs

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///
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    /// Inspect manifest and journal agreement without reading artifact bytes.
86    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    /// Build an audit-oriented provenance report without reading artifact bytes.
93    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///
101/// BackupProvenanceReport
102///
103
104#[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///
134/// BackupUnitProvenance
135///
136
137#[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///
149/// MemberSnapshotProvenance
150///
151
152#[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///
175/// BackupInspectionReport
176///
177
178#[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///
197/// TopologyReceiptMismatch
198///
199
200#[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///
208/// ArtifactReference
209///
210
211#[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///
218/// ArtifactPathMismatch
219///
220
221#[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///
230/// ArtifactChecksumMismatch
231///
232
233#[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///
242/// BackupIntegrityReport
243///
244
245#[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///
256/// ArtifactIntegrityReport
257///
258
259#[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///
268/// PersistenceError
269///
270
271#[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
340// Inspect manifest and journal agreement without touching artifact contents.
341fn 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
432// Build an audit-friendly manifest and journal provenance projection.
433fn 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
519// Verify cross-file backup layout consistency and artifact checksums.
520fn 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
629// Build the stable key used to compare manifest members with journal artifacts.
630fn artifact_key(canister_id: &str, snapshot_id: &str) -> (String, String) {
631    (canister_id.to_string(), snapshot_id.to_string())
632}
633
634// Convert one artifact key into the public report shape.
635fn artifact_reference(key: &(String, String)) -> ArtifactReference {
636    ArtifactReference {
637        canister_id: key.0.clone(),
638        snapshot_id: key.1.clone(),
639    }
640}
641
642// Compare manifest and journal topology receipts for fail-closed verification.
643fn 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
663// Record one manifest/journal topology receipt mismatch.
664fn 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
681// Return the stable serialized name for a consistency mode.
682const fn consistency_mode_name(mode: &ConsistencyMode) -> &'static str {
683    match mode {
684        ConsistencyMode::CrashConsistent => "crash-consistent",
685        ConsistencyMode::QuiescedUnit => "quiesced-unit",
686    }
687}
688
689// Return the stable serialized name for a backup unit kind.
690const 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
699// Return the stable serialized name for an identity mode.
700const 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
707// Return the stable serialized name for a journal artifact state.
708const 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
717// Resolve artifact paths from either absolute, cwd-relative, or layout-relative values.
718fn 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
727// Write JSON to a temporary sibling path and then atomically replace the target.
728fn 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
751// Read one JSON document from disk.
752fn 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
760// Build the sibling temporary path used for atomic writes.
761fn 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    // Ensure manifest writes create parent dirs and round-trip through validation.
792    #[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    // Ensure journal writes create parent dirs and round-trip through validation.
808    #[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    // Ensure invalid manifests are rejected before writing.
824    #[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    // Ensure inspection reports manifest and journal agreement without artifact IO.
842    #[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    // Ensure inspection surfaces path and checksum drift before full verification.
872    #[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    // Ensure inspection reports missing and unexpected artifact boundaries.
901    #[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    // Ensure provenance reports source, topology, unit, and snapshot metadata.
923    #[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    // Ensure layout integrity verifies manifest, journal, and artifact checksums.
964    #[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    // Ensure mismatched manifest and journal backup IDs are rejected.
987    #[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    // Ensure manifest and journal topology receipts cannot silently diverge.
1009    #[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    // Ensure incomplete journals cannot pass backup integrity verification.
1035    #[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    // Ensure journals cannot include artifacts outside the manifest boundary.
1057    #[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    // Ensure manifest snapshot checksums cannot drift from the durable journal.
1084    #[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    // Ensure manifest and journal artifact paths cannot silently diverge.
1110    #[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    // Build one valid manifest for persistence tests.
1136    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    // Build one valid durable journal for persistence tests.
1200    fn valid_journal() -> DownloadJournal {
1201        journal_with_checksum(HASH.to_string())
1202    }
1203
1204    // Build one durable journal with a caller-provided checksum.
1205    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    // Write one artifact at the layout-relative path used by test journals.
1225    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    // Build a unique temporary layout directory.
1233    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}