Skip to main content

canic_backup/persistence/
mod.rs

1use crate::{
2    artifacts::{ArtifactChecksum, ArtifactChecksumError},
3    journal::{ArtifactState, DownloadJournal},
4    manifest::{
5        FleetBackupManifest, ManifestValidationError, backup_unit_kind_name, consistency_mode_name,
6    },
7};
8use serde::{Deserialize, Serialize, de::DeserializeOwned};
9use std::{
10    collections::{BTreeMap, BTreeSet},
11    fs::{self, File},
12    io,
13    path::{Path, PathBuf},
14};
15use thiserror::Error as ThisError;
16
17const MANIFEST_FILE_NAME: &str = "fleet-backup-manifest.json";
18const JOURNAL_FILE_NAME: &str = "download-journal.json";
19
20///
21/// BackupLayout
22///
23
24#[derive(Clone, Debug)]
25pub struct BackupLayout {
26    root: PathBuf,
27}
28
29impl BackupLayout {
30    /// Create a filesystem layout rooted at one backup directory.
31    #[must_use]
32    pub const fn new(root: PathBuf) -> Self {
33        Self { root }
34    }
35
36    /// Return the root backup directory path.
37    #[must_use]
38    pub fn root(&self) -> &Path {
39        &self.root
40    }
41
42    /// Return the canonical manifest path for this backup layout.
43    #[must_use]
44    pub fn manifest_path(&self) -> PathBuf {
45        self.root.join(MANIFEST_FILE_NAME)
46    }
47
48    /// Return the canonical mutable journal path for this backup layout.
49    #[must_use]
50    pub fn journal_path(&self) -> PathBuf {
51        self.root.join(JOURNAL_FILE_NAME)
52    }
53
54    /// Write a validated manifest with atomic replace semantics.
55    pub fn write_manifest(&self, manifest: &FleetBackupManifest) -> Result<(), PersistenceError> {
56        manifest.validate()?;
57        write_json_atomic(&self.manifest_path(), manifest)
58    }
59
60    /// Read and validate a manifest from this backup layout.
61    pub fn read_manifest(&self) -> Result<FleetBackupManifest, PersistenceError> {
62        let manifest = read_json(&self.manifest_path())?;
63        FleetBackupManifest::validate(&manifest)?;
64        Ok(manifest)
65    }
66
67    /// Write a validated download journal with atomic replace semantics.
68    pub fn write_journal(&self, journal: &DownloadJournal) -> Result<(), PersistenceError> {
69        journal.validate()?;
70        write_json_atomic(&self.journal_path(), journal)
71    }
72
73    /// Read and validate a download journal from this backup layout.
74    pub fn read_journal(&self) -> Result<DownloadJournal, PersistenceError> {
75        let journal = read_json(&self.journal_path())?;
76        DownloadJournal::validate(&journal)?;
77        Ok(journal)
78    }
79
80    /// Validate the manifest, journal, and durable artifact checksums.
81    pub fn verify_integrity(&self) -> Result<BackupIntegrityReport, PersistenceError> {
82        let manifest = self.read_manifest()?;
83        let journal = self.read_journal()?;
84        verify_layout_integrity(self, &manifest, &journal)
85    }
86
87    /// Inspect manifest and journal agreement without reading artifact bytes.
88    pub fn inspect(&self) -> Result<BackupInspectionReport, PersistenceError> {
89        let manifest = self.read_manifest()?;
90        let journal = self.read_journal()?;
91        Ok(inspect_layout(&manifest, &journal))
92    }
93
94    /// Build an audit-oriented provenance report without reading artifact bytes.
95    pub fn provenance(&self) -> Result<BackupProvenanceReport, PersistenceError> {
96        let manifest = self.read_manifest()?;
97        let journal = self.read_journal()?;
98        Ok(provenance_report(&manifest, &journal))
99    }
100}
101
102///
103/// BackupProvenanceReport
104///
105
106#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
107pub struct BackupProvenanceReport {
108    pub backup_id: String,
109    pub manifest_backup_id: String,
110    pub journal_backup_id: String,
111    pub backup_id_matches: bool,
112    pub manifest_version: u16,
113    pub journal_version: u16,
114    pub created_at: String,
115    pub tool_name: String,
116    pub tool_version: String,
117    pub source_environment: String,
118    pub source_root_canister: String,
119    pub topology_hash_algorithm: String,
120    pub topology_hash_input: String,
121    pub discovery_topology_hash: String,
122    pub pre_snapshot_topology_hash: String,
123    pub accepted_topology_hash: String,
124    pub journal_discovery_topology_hash: Option<String>,
125    pub journal_pre_snapshot_topology_hash: Option<String>,
126    pub topology_receipts_match: bool,
127    pub topology_receipt_mismatches: Vec<TopologyReceiptMismatch>,
128    pub backup_unit_count: usize,
129    pub member_count: usize,
130    pub consistency_mode: String,
131    pub backup_units: Vec<BackupUnitProvenance>,
132    pub members: Vec<MemberSnapshotProvenance>,
133}
134
135///
136/// BackupUnitProvenance
137///
138
139#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
140pub struct BackupUnitProvenance {
141    pub unit_id: String,
142    pub kind: String,
143    pub roles: Vec<String>,
144    pub consistency_reason: Option<String>,
145    pub dependency_closure: Vec<String>,
146    pub topology_validation: String,
147    pub quiescence_strategy: Option<String>,
148}
149
150///
151/// MemberSnapshotProvenance
152///
153
154#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
155pub struct MemberSnapshotProvenance {
156    pub canister_id: String,
157    pub role: String,
158    pub parent_canister_id: Option<String>,
159    pub subnet_canister_id: Option<String>,
160    pub identity_mode: String,
161    pub restore_group: u16,
162    pub verification_class: String,
163    pub verification_checks: usize,
164    pub snapshot_id: String,
165    pub module_hash: Option<String>,
166    pub wasm_hash: Option<String>,
167    pub code_version: Option<String>,
168    pub artifact_path: String,
169    pub checksum_algorithm: String,
170    pub manifest_checksum: Option<String>,
171    pub journal_state: Option<String>,
172    pub journal_checksum: Option<String>,
173    pub journal_updated_at: Option<String>,
174}
175
176///
177/// BackupInspectionReport
178///
179
180#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
181pub struct BackupInspectionReport {
182    pub backup_id: String,
183    pub manifest_backup_id: String,
184    pub journal_backup_id: String,
185    pub backup_id_matches: bool,
186    pub journal_complete: bool,
187    pub ready_for_verify: bool,
188    pub manifest_members: usize,
189    pub journal_artifacts: usize,
190    pub matched_artifacts: usize,
191    pub topology_receipt_mismatches: Vec<TopologyReceiptMismatch>,
192    pub missing_journal_artifacts: Vec<ArtifactReference>,
193    pub unexpected_journal_artifacts: Vec<ArtifactReference>,
194    pub path_mismatches: Vec<ArtifactPathMismatch>,
195    pub checksum_mismatches: Vec<ArtifactChecksumMismatch>,
196}
197
198///
199/// TopologyReceiptMismatch
200///
201
202#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
203pub struct TopologyReceiptMismatch {
204    pub field: String,
205    pub manifest: String,
206    pub journal: Option<String>,
207}
208
209///
210/// ArtifactReference
211///
212
213#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
214pub struct ArtifactReference {
215    pub canister_id: String,
216    pub snapshot_id: String,
217}
218
219///
220/// ArtifactPathMismatch
221///
222
223#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
224pub struct ArtifactPathMismatch {
225    pub canister_id: String,
226    pub snapshot_id: String,
227    pub manifest: String,
228    pub journal: String,
229}
230
231///
232/// ArtifactChecksumMismatch
233///
234
235#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
236pub struct ArtifactChecksumMismatch {
237    pub canister_id: String,
238    pub snapshot_id: String,
239    pub manifest: String,
240    pub journal: String,
241}
242
243///
244/// BackupIntegrityReport
245///
246
247#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
248pub struct BackupIntegrityReport {
249    pub backup_id: String,
250    pub verified: bool,
251    pub manifest_members: usize,
252    pub journal_artifacts: usize,
253    pub durable_artifacts: usize,
254    pub artifacts: Vec<ArtifactIntegrityReport>,
255}
256
257///
258/// ArtifactIntegrityReport
259///
260
261#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
262pub struct ArtifactIntegrityReport {
263    pub canister_id: String,
264    pub snapshot_id: String,
265    pub artifact_path: String,
266    pub checksum: String,
267}
268
269///
270/// PersistenceError
271///
272
273#[derive(Debug, ThisError)]
274pub enum PersistenceError {
275    #[error(transparent)]
276    Io(#[from] io::Error),
277
278    #[error(transparent)]
279    Json(#[from] serde_json::Error),
280
281    #[error(transparent)]
282    InvalidManifest(#[from] ManifestValidationError),
283
284    #[error(transparent)]
285    InvalidJournal(#[from] crate::journal::JournalValidationError),
286
287    #[error(transparent)]
288    Checksum(#[from] ArtifactChecksumError),
289
290    #[error("manifest backup id {manifest} does not match journal backup id {journal}")]
291    BackupIdMismatch { manifest: String, journal: String },
292
293    #[error("journal artifact {canister_id} snapshot {snapshot_id} is not durable")]
294    NonDurableArtifact {
295        canister_id: String,
296        snapshot_id: String,
297    },
298
299    #[error("manifest member {canister_id} snapshot {snapshot_id} has no journal artifact")]
300    MissingJournalArtifact {
301        canister_id: String,
302        snapshot_id: String,
303    },
304
305    #[error("journal artifact {canister_id} snapshot {snapshot_id} is not declared in manifest")]
306    UnexpectedJournalArtifact {
307        canister_id: String,
308        snapshot_id: String,
309    },
310
311    #[error(
312        "manifest checksum for {canister_id} snapshot {snapshot_id} does not match journal checksum"
313    )]
314    ManifestJournalChecksumMismatch {
315        canister_id: String,
316        snapshot_id: String,
317        manifest: String,
318        journal: String,
319    },
320
321    #[error(
322        "manifest artifact path for {canister_id} snapshot {snapshot_id} does not match journal artifact path"
323    )]
324    ManifestJournalArtifactPathMismatch {
325        canister_id: String,
326        snapshot_id: String,
327        manifest: String,
328        journal: String,
329    },
330
331    #[error("manifest topology receipt {field} does not match journal topology receipt")]
332    ManifestJournalTopologyReceiptMismatch {
333        field: String,
334        manifest: String,
335        journal: Option<String>,
336    },
337
338    #[error("artifact path does not exist: {0}")]
339    MissingArtifact(String),
340}
341
342// Inspect manifest and journal agreement without touching artifact contents.
343fn inspect_layout(
344    manifest: &FleetBackupManifest,
345    journal: &DownloadJournal,
346) -> BackupInspectionReport {
347    let journal_report = journal.resume_report();
348    let journal_artifacts = journal
349        .artifacts
350        .iter()
351        .map(|entry| (artifact_key(&entry.canister_id, &entry.snapshot_id), entry))
352        .collect::<BTreeMap<_, _>>();
353    let manifest_artifacts = manifest
354        .fleet
355        .members
356        .iter()
357        .map(|member| {
358            (
359                artifact_key(&member.canister_id, &member.source_snapshot.snapshot_id),
360                member,
361            )
362        })
363        .collect::<BTreeMap<_, _>>();
364
365    let mut matched_artifacts = 0;
366    let mut missing_journal_artifacts = Vec::new();
367    let mut path_mismatches = Vec::new();
368    let mut checksum_mismatches = Vec::new();
369
370    for (key, member) in &manifest_artifacts {
371        let Some(entry) = journal_artifacts.get(key) else {
372            missing_journal_artifacts.push(artifact_reference(key));
373            continue;
374        };
375
376        matched_artifacts += 1;
377        if member.source_snapshot.artifact_path != entry.artifact_path {
378            path_mismatches.push(ArtifactPathMismatch {
379                canister_id: key.0.clone(),
380                snapshot_id: key.1.clone(),
381                manifest: member.source_snapshot.artifact_path.clone(),
382                journal: entry.artifact_path.clone(),
383            });
384        }
385
386        if let (Some(manifest_hash), Some(journal_hash)) = (
387            member.source_snapshot.checksum.as_deref(),
388            entry.checksum.as_deref(),
389        ) && manifest_hash != journal_hash
390        {
391            checksum_mismatches.push(ArtifactChecksumMismatch {
392                canister_id: key.0.clone(),
393                snapshot_id: key.1.clone(),
394                manifest: manifest_hash.to_string(),
395                journal: journal_hash.to_string(),
396            });
397        }
398    }
399
400    let unexpected_journal_artifacts = journal_artifacts
401        .keys()
402        .filter(|key| !manifest_artifacts.contains_key(*key))
403        .map(artifact_reference)
404        .collect::<Vec<_>>();
405    let topology_receipt_mismatches = topology_receipt_mismatches(manifest, journal);
406    let topology_receipts_match = topology_receipt_mismatches.is_empty();
407    let backup_id_matches = manifest.backup_id == journal.backup_id;
408    let ready_for_verify = backup_id_matches
409        && topology_receipts_match
410        && journal_report.is_complete
411        && missing_journal_artifacts.is_empty()
412        && unexpected_journal_artifacts.is_empty()
413        && path_mismatches.is_empty()
414        && checksum_mismatches.is_empty();
415
416    BackupInspectionReport {
417        backup_id: manifest.backup_id.clone(),
418        manifest_backup_id: manifest.backup_id.clone(),
419        journal_backup_id: journal.backup_id.clone(),
420        backup_id_matches,
421        journal_complete: journal_report.is_complete,
422        ready_for_verify,
423        manifest_members: manifest.fleet.members.len(),
424        journal_artifacts: journal.artifacts.len(),
425        matched_artifacts,
426        topology_receipt_mismatches,
427        missing_journal_artifacts,
428        unexpected_journal_artifacts,
429        path_mismatches,
430        checksum_mismatches,
431    }
432}
433
434// Build an audit-friendly manifest and journal provenance projection.
435fn provenance_report(
436    manifest: &FleetBackupManifest,
437    journal: &DownloadJournal,
438) -> BackupProvenanceReport {
439    let journal_artifacts = journal
440        .artifacts
441        .iter()
442        .map(|entry| (artifact_key(&entry.canister_id, &entry.snapshot_id), entry))
443        .collect::<BTreeMap<_, _>>();
444    let topology_receipt_mismatches = topology_receipt_mismatches(manifest, journal);
445    let topology_receipts_match = topology_receipt_mismatches.is_empty();
446
447    BackupProvenanceReport {
448        backup_id: manifest.backup_id.clone(),
449        manifest_backup_id: manifest.backup_id.clone(),
450        journal_backup_id: journal.backup_id.clone(),
451        backup_id_matches: manifest.backup_id == journal.backup_id,
452        manifest_version: manifest.manifest_version,
453        journal_version: journal.journal_version,
454        created_at: manifest.created_at.clone(),
455        tool_name: manifest.tool.name.clone(),
456        tool_version: manifest.tool.version.clone(),
457        source_environment: manifest.source.environment.clone(),
458        source_root_canister: manifest.source.root_canister.clone(),
459        topology_hash_algorithm: manifest.fleet.topology_hash_algorithm.clone(),
460        topology_hash_input: manifest.fleet.topology_hash_input.clone(),
461        discovery_topology_hash: manifest.fleet.discovery_topology_hash.clone(),
462        pre_snapshot_topology_hash: manifest.fleet.pre_snapshot_topology_hash.clone(),
463        accepted_topology_hash: manifest.fleet.topology_hash.clone(),
464        journal_discovery_topology_hash: journal.discovery_topology_hash.clone(),
465        journal_pre_snapshot_topology_hash: journal.pre_snapshot_topology_hash.clone(),
466        topology_receipts_match,
467        topology_receipt_mismatches,
468        backup_unit_count: manifest.consistency.backup_units.len(),
469        member_count: manifest.fleet.members.len(),
470        consistency_mode: consistency_mode_name(&manifest.consistency.mode).to_string(),
471        backup_units: manifest
472            .consistency
473            .backup_units
474            .iter()
475            .map(|unit| BackupUnitProvenance {
476                unit_id: unit.unit_id.clone(),
477                kind: backup_unit_kind_name(&unit.kind).to_string(),
478                roles: unit.roles.clone(),
479                consistency_reason: unit.consistency_reason.clone(),
480                dependency_closure: unit.dependency_closure.clone(),
481                topology_validation: unit.topology_validation.clone(),
482                quiescence_strategy: unit.quiescence_strategy.clone(),
483            })
484            .collect(),
485        members: manifest
486            .fleet
487            .members
488            .iter()
489            .map(|member| {
490                let journal_entry = journal_artifacts.get(&artifact_key(
491                    &member.canister_id,
492                    &member.source_snapshot.snapshot_id,
493                ));
494
495                MemberSnapshotProvenance {
496                    canister_id: member.canister_id.clone(),
497                    role: member.role.clone(),
498                    parent_canister_id: member.parent_canister_id.clone(),
499                    subnet_canister_id: member.subnet_canister_id.clone(),
500                    identity_mode: identity_mode_name(&member.identity_mode).to_string(),
501                    restore_group: member.restore_group,
502                    verification_class: member.verification_class.clone(),
503                    verification_checks: member.verification_checks.len(),
504                    snapshot_id: member.source_snapshot.snapshot_id.clone(),
505                    module_hash: member.source_snapshot.module_hash.clone(),
506                    wasm_hash: member.source_snapshot.wasm_hash.clone(),
507                    code_version: member.source_snapshot.code_version.clone(),
508                    artifact_path: member.source_snapshot.artifact_path.clone(),
509                    checksum_algorithm: member.source_snapshot.checksum_algorithm.clone(),
510                    manifest_checksum: member.source_snapshot.checksum.clone(),
511                    journal_state: journal_entry
512                        .map(|entry| artifact_state_name(entry.state).to_string()),
513                    journal_checksum: journal_entry.and_then(|entry| entry.checksum.clone()),
514                    journal_updated_at: journal_entry.map(|entry| entry.updated_at.clone()),
515                }
516            })
517            .collect(),
518    }
519}
520
521// Verify cross-file backup layout consistency and artifact checksums.
522fn verify_layout_integrity(
523    layout: &BackupLayout,
524    manifest: &FleetBackupManifest,
525    journal: &DownloadJournal,
526) -> Result<BackupIntegrityReport, PersistenceError> {
527    if manifest.backup_id != journal.backup_id {
528        return Err(PersistenceError::BackupIdMismatch {
529            manifest: manifest.backup_id.clone(),
530            journal: journal.backup_id.clone(),
531        });
532    }
533
534    if let Some(mismatch) = topology_receipt_mismatches(manifest, journal)
535        .into_iter()
536        .next()
537    {
538        return Err(PersistenceError::ManifestJournalTopologyReceiptMismatch {
539            field: mismatch.field,
540            manifest: mismatch.manifest,
541            journal: mismatch.journal,
542        });
543    }
544
545    let expected_artifacts = manifest
546        .fleet
547        .members
548        .iter()
549        .map(|member| {
550            (
551                member.canister_id.as_str(),
552                member.source_snapshot.snapshot_id.as_str(),
553            )
554        })
555        .collect::<BTreeSet<_>>();
556    for entry in &journal.artifacts {
557        if !expected_artifacts.contains(&(entry.canister_id.as_str(), entry.snapshot_id.as_str())) {
558            return Err(PersistenceError::UnexpectedJournalArtifact {
559                canister_id: entry.canister_id.clone(),
560                snapshot_id: entry.snapshot_id.clone(),
561            });
562        }
563    }
564
565    let mut artifacts = Vec::with_capacity(journal.artifacts.len());
566    for member in &manifest.fleet.members {
567        let Some(entry) = journal.artifacts.iter().find(|entry| {
568            entry.canister_id == member.canister_id
569                && entry.snapshot_id == member.source_snapshot.snapshot_id
570        }) else {
571            return Err(PersistenceError::MissingJournalArtifact {
572                canister_id: member.canister_id.clone(),
573                snapshot_id: member.source_snapshot.snapshot_id.clone(),
574            });
575        };
576
577        if entry.state != ArtifactState::Durable {
578            return Err(PersistenceError::NonDurableArtifact {
579                canister_id: entry.canister_id.clone(),
580                snapshot_id: entry.snapshot_id.clone(),
581            });
582        }
583
584        let Some(expected_hash) = entry.checksum.as_deref() else {
585            unreachable!("validated durable journals must include checksums");
586        };
587        if member.source_snapshot.artifact_path != entry.artifact_path {
588            return Err(PersistenceError::ManifestJournalArtifactPathMismatch {
589                canister_id: entry.canister_id.clone(),
590                snapshot_id: entry.snapshot_id.clone(),
591                manifest: member.source_snapshot.artifact_path.clone(),
592                journal: entry.artifact_path.clone(),
593            });
594        }
595        if let Some(manifest_hash) = member.source_snapshot.checksum.as_deref()
596            && manifest_hash != expected_hash
597        {
598            return Err(PersistenceError::ManifestJournalChecksumMismatch {
599                canister_id: entry.canister_id.clone(),
600                snapshot_id: entry.snapshot_id.clone(),
601                manifest: manifest_hash.to_string(),
602                journal: expected_hash.to_string(),
603            });
604        }
605        let artifact_path = resolve_artifact_path(layout.root(), &entry.artifact_path);
606        if !artifact_path.exists() {
607            return Err(PersistenceError::MissingArtifact(
608                artifact_path.display().to_string(),
609            ));
610        }
611
612        ArtifactChecksum::from_path(&artifact_path)?.verify(expected_hash)?;
613        artifacts.push(ArtifactIntegrityReport {
614            canister_id: entry.canister_id.clone(),
615            snapshot_id: entry.snapshot_id.clone(),
616            artifact_path: artifact_path.display().to_string(),
617            checksum: expected_hash.to_string(),
618        });
619    }
620
621    Ok(BackupIntegrityReport {
622        backup_id: manifest.backup_id.clone(),
623        verified: true,
624        manifest_members: manifest.fleet.members.len(),
625        journal_artifacts: journal.artifacts.len(),
626        durable_artifacts: artifacts.len(),
627        artifacts,
628    })
629}
630
631// Build the stable key used to compare manifest members with journal artifacts.
632fn artifact_key(canister_id: &str, snapshot_id: &str) -> (String, String) {
633    (canister_id.to_string(), snapshot_id.to_string())
634}
635
636// Convert one artifact key into the public report shape.
637fn artifact_reference(key: &(String, String)) -> ArtifactReference {
638    ArtifactReference {
639        canister_id: key.0.clone(),
640        snapshot_id: key.1.clone(),
641    }
642}
643
644// Compare manifest and journal topology receipts for fail-closed verification.
645fn topology_receipt_mismatches(
646    manifest: &FleetBackupManifest,
647    journal: &DownloadJournal,
648) -> Vec<TopologyReceiptMismatch> {
649    let mut mismatches = Vec::new();
650    record_topology_receipt_mismatch(
651        &mut mismatches,
652        "discovery_topology_hash",
653        &manifest.fleet.discovery_topology_hash,
654        journal.discovery_topology_hash.as_deref(),
655    );
656    record_topology_receipt_mismatch(
657        &mut mismatches,
658        "pre_snapshot_topology_hash",
659        &manifest.fleet.pre_snapshot_topology_hash,
660        journal.pre_snapshot_topology_hash.as_deref(),
661    );
662    mismatches
663}
664
665// Record one manifest/journal topology receipt mismatch.
666fn record_topology_receipt_mismatch(
667    mismatches: &mut Vec<TopologyReceiptMismatch>,
668    field: &str,
669    manifest: &str,
670    journal: Option<&str>,
671) {
672    if journal == Some(manifest) {
673        return;
674    }
675
676    mismatches.push(TopologyReceiptMismatch {
677        field: field.to_string(),
678        manifest: manifest.to_string(),
679        journal: journal.map(ToString::to_string),
680    });
681}
682
683// Return the stable serialized name for an identity mode.
684const fn identity_mode_name(mode: &crate::manifest::IdentityMode) -> &'static str {
685    match mode {
686        crate::manifest::IdentityMode::Fixed => "fixed",
687        crate::manifest::IdentityMode::Relocatable => "relocatable",
688    }
689}
690
691// Return the stable serialized name for a journal artifact state.
692const fn artifact_state_name(state: ArtifactState) -> &'static str {
693    match state {
694        ArtifactState::Created => "Created",
695        ArtifactState::Downloaded => "Downloaded",
696        ArtifactState::ChecksumVerified => "ChecksumVerified",
697        ArtifactState::Durable => "Durable",
698    }
699}
700
701// Resolve artifact paths from either absolute, cwd-relative, or layout-relative values.
702fn resolve_artifact_path(root: &Path, artifact_path: &str) -> PathBuf {
703    let path = PathBuf::from(artifact_path);
704    if path.is_absolute() || path.exists() {
705        path
706    } else {
707        root.join(path)
708    }
709}
710
711// Write JSON to a temporary sibling path and then atomically replace the target.
712fn write_json_atomic<T>(path: &Path, value: &T) -> Result<(), PersistenceError>
713where
714    T: Serialize,
715{
716    if let Some(parent) = path.parent() {
717        fs::create_dir_all(parent)?;
718    }
719
720    let tmp_path = temp_path_for(path);
721    let mut file = File::create(&tmp_path)?;
722    serde_json::to_writer_pretty(&mut file, value)?;
723    file.sync_all()?;
724    drop(file);
725
726    fs::rename(&tmp_path, path)?;
727
728    if let Some(parent) = path.parent() {
729        File::open(parent)?.sync_all()?;
730    }
731
732    Ok(())
733}
734
735// Read one JSON document from disk.
736fn read_json<T>(path: &Path) -> Result<T, PersistenceError>
737where
738    T: DeserializeOwned,
739{
740    let file = File::open(path)?;
741    Ok(serde_json::from_reader(file)?)
742}
743
744// Build the sibling temporary path used for atomic writes.
745fn temp_path_for(path: &Path) -> PathBuf {
746    let mut file_name = path
747        .file_name()
748        .and_then(|name| name.to_str())
749        .unwrap_or("canic-backup")
750        .to_string();
751    file_name.push_str(".tmp");
752    path.with_file_name(file_name)
753}
754
755#[cfg(test)]
756mod tests;