Skip to main content

canic_backup/persistence/
mod.rs

1use crate::{
2    journal::DownloadJournal,
3    manifest::{FleetBackupManifest, ManifestValidationError},
4};
5use serde::{Serialize, de::DeserializeOwned};
6use std::{
7    fs::{self, File},
8    io,
9    path::{Path, PathBuf},
10};
11use thiserror::Error as ThisError;
12
13const MANIFEST_FILE_NAME: &str = "fleet-backup-manifest.json";
14const JOURNAL_FILE_NAME: &str = "download-journal.json";
15
16///
17/// BackupLayout
18///
19
20#[derive(Clone, Debug)]
21pub struct BackupLayout {
22    root: PathBuf,
23}
24
25impl BackupLayout {
26    /// Create a filesystem layout rooted at one backup directory.
27    #[must_use]
28    pub const fn new(root: PathBuf) -> Self {
29        Self { root }
30    }
31
32    /// Return the root backup directory path.
33    #[must_use]
34    pub fn root(&self) -> &Path {
35        &self.root
36    }
37
38    /// Return the canonical manifest path for this backup layout.
39    #[must_use]
40    pub fn manifest_path(&self) -> PathBuf {
41        self.root.join(MANIFEST_FILE_NAME)
42    }
43
44    /// Return the canonical mutable journal path for this backup layout.
45    #[must_use]
46    pub fn journal_path(&self) -> PathBuf {
47        self.root.join(JOURNAL_FILE_NAME)
48    }
49
50    /// Write a validated manifest with atomic replace semantics.
51    pub fn write_manifest(&self, manifest: &FleetBackupManifest) -> Result<(), PersistenceError> {
52        manifest.validate()?;
53        write_json_atomic(&self.manifest_path(), manifest)
54    }
55
56    /// Read and validate a manifest from this backup layout.
57    pub fn read_manifest(&self) -> Result<FleetBackupManifest, PersistenceError> {
58        let manifest = read_json(&self.manifest_path())?;
59        FleetBackupManifest::validate(&manifest)?;
60        Ok(manifest)
61    }
62
63    /// Write a validated download journal with atomic replace semantics.
64    pub fn write_journal(&self, journal: &DownloadJournal) -> Result<(), PersistenceError> {
65        journal.validate()?;
66        write_json_atomic(&self.journal_path(), journal)
67    }
68
69    /// Read and validate a download journal from this backup layout.
70    pub fn read_journal(&self) -> Result<DownloadJournal, PersistenceError> {
71        let journal = read_json(&self.journal_path())?;
72        DownloadJournal::validate(&journal)?;
73        Ok(journal)
74    }
75}
76
77///
78/// PersistenceError
79///
80
81#[derive(Debug, ThisError)]
82pub enum PersistenceError {
83    #[error(transparent)]
84    Io(#[from] io::Error),
85
86    #[error(transparent)]
87    Json(#[from] serde_json::Error),
88
89    #[error(transparent)]
90    InvalidManifest(#[from] ManifestValidationError),
91
92    #[error(transparent)]
93    InvalidJournal(#[from] crate::journal::JournalValidationError),
94}
95
96// Write JSON to a temporary sibling path and then atomically replace the target.
97fn write_json_atomic<T>(path: &Path, value: &T) -> Result<(), PersistenceError>
98where
99    T: Serialize,
100{
101    if let Some(parent) = path.parent() {
102        fs::create_dir_all(parent)?;
103    }
104
105    let tmp_path = temp_path_for(path);
106    let mut file = File::create(&tmp_path)?;
107    serde_json::to_writer_pretty(&mut file, value)?;
108    file.sync_all()?;
109    drop(file);
110
111    fs::rename(&tmp_path, path)?;
112
113    if let Some(parent) = path.parent() {
114        File::open(parent)?.sync_all()?;
115    }
116
117    Ok(())
118}
119
120// Read one JSON document from disk.
121fn read_json<T>(path: &Path) -> Result<T, PersistenceError>
122where
123    T: DeserializeOwned,
124{
125    let file = File::open(path)?;
126    Ok(serde_json::from_reader(file)?)
127}
128
129// Build the sibling temporary path used for atomic writes.
130fn temp_path_for(path: &Path) -> PathBuf {
131    let mut file_name = path
132        .file_name()
133        .and_then(|name| name.to_str())
134        .unwrap_or("canic-backup")
135        .to_string();
136    file_name.push_str(".tmp");
137    path.with_file_name(file_name)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::{
144        journal::{ArtifactJournalEntry, ArtifactState},
145        manifest::{
146            BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember,
147            FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
148            VerificationCheck, VerificationPlan,
149        },
150    };
151    use std::{
152        fs,
153        time::{SystemTime, UNIX_EPOCH},
154    };
155
156    const ROOT: &str = "aaaaa-aa";
157    const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
158    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
159
160    // Ensure manifest writes create parent dirs and round-trip through validation.
161    #[test]
162    fn manifest_round_trips_through_layout() {
163        let root = temp_dir("canic-backup-manifest-layout");
164        let layout = BackupLayout::new(root.clone());
165        let manifest = valid_manifest();
166
167        layout
168            .write_manifest(&manifest)
169            .expect("write manifest atomically");
170        let read = layout.read_manifest().expect("read manifest");
171
172        fs::remove_dir_all(root).expect("remove temp layout");
173        assert_eq!(read.backup_id, manifest.backup_id);
174    }
175
176    // Ensure journal writes create parent dirs and round-trip through validation.
177    #[test]
178    fn journal_round_trips_through_layout() {
179        let root = temp_dir("canic-backup-journal-layout");
180        let layout = BackupLayout::new(root.clone());
181        let journal = valid_journal();
182
183        layout
184            .write_journal(&journal)
185            .expect("write journal atomically");
186        let read = layout.read_journal().expect("read journal");
187
188        fs::remove_dir_all(root).expect("remove temp layout");
189        assert_eq!(read.backup_id, journal.backup_id);
190    }
191
192    // Ensure invalid manifests are rejected before writing.
193    #[test]
194    fn invalid_manifest_is_not_written() {
195        let root = temp_dir("canic-backup-invalid-manifest");
196        let layout = BackupLayout::new(root.clone());
197        let mut manifest = valid_manifest();
198        manifest.fleet.discovery_topology_hash = "bad".to_string();
199
200        let err = layout
201            .write_manifest(&manifest)
202            .expect_err("invalid manifest should fail");
203
204        let manifest_path = layout.manifest_path();
205        fs::remove_dir_all(root).ok();
206        assert!(matches!(err, PersistenceError::InvalidManifest(_)));
207        assert!(!manifest_path.exists());
208    }
209
210    // Build one valid manifest for persistence tests.
211    fn valid_manifest() -> FleetBackupManifest {
212        FleetBackupManifest {
213            manifest_version: 1,
214            backup_id: "fbk_test_001".to_string(),
215            created_at: "2026-04-10T12:00:00Z".to_string(),
216            tool: ToolMetadata {
217                name: "canic".to_string(),
218                version: "v1".to_string(),
219            },
220            source: SourceMetadata {
221                environment: "local".to_string(),
222                root_canister: ROOT.to_string(),
223            },
224            consistency: ConsistencySection {
225                mode: ConsistencyMode::CrashConsistent,
226                backup_units: vec![BackupUnit {
227                    unit_id: "whole-fleet".to_string(),
228                    kind: BackupUnitKind::WholeFleet,
229                    roles: vec!["root".to_string()],
230                    consistency_reason: None,
231                    dependency_closure: Vec::new(),
232                    topology_validation: "subtree-closed".to_string(),
233                    quiescence_strategy: None,
234                }],
235            },
236            fleet: FleetSection {
237                topology_hash_algorithm: "sha256".to_string(),
238                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
239                discovery_topology_hash: HASH.to_string(),
240                pre_snapshot_topology_hash: HASH.to_string(),
241                topology_hash: HASH.to_string(),
242                members: vec![FleetMember {
243                    role: "root".to_string(),
244                    canister_id: ROOT.to_string(),
245                    parent_canister_id: None,
246                    subnet_canister_id: Some(CHILD.to_string()),
247                    controller_hint: Some(ROOT.to_string()),
248                    identity_mode: IdentityMode::Fixed,
249                    restore_group: 1,
250                    verification_class: "basic".to_string(),
251                    verification_checks: vec![VerificationCheck {
252                        kind: "call".to_string(),
253                        method: Some("canic_ready".to_string()),
254                        roles: Vec::new(),
255                    }],
256                    source_snapshot: SourceSnapshot {
257                        snapshot_id: "snap-root".to_string(),
258                        module_hash: Some(HASH.to_string()),
259                        wasm_hash: Some(HASH.to_string()),
260                        code_version: Some("v0.30.0".to_string()),
261                        artifact_path: "artifacts/root".to_string(),
262                        checksum_algorithm: "sha256".to_string(),
263                    },
264                }],
265            },
266            verification: VerificationPlan {
267                fleet_checks: Vec::new(),
268                member_checks: Vec::new(),
269            },
270        }
271    }
272
273    // Build one valid durable journal for persistence tests.
274    fn valid_journal() -> DownloadJournal {
275        DownloadJournal {
276            journal_version: 1,
277            backup_id: "fbk_test_001".to_string(),
278            artifacts: vec![ArtifactJournalEntry {
279                canister_id: ROOT.to_string(),
280                snapshot_id: "snap-root".to_string(),
281                state: ArtifactState::Durable,
282                temp_path: None,
283                artifact_path: "artifacts/root".to_string(),
284                checksum_algorithm: "sha256".to_string(),
285                checksum: Some(HASH.to_string()),
286                updated_at: "2026-04-10T12:00:00Z".to_string(),
287            }],
288        }
289    }
290
291    // Build a unique temporary layout directory.
292    fn temp_dir(prefix: &str) -> PathBuf {
293        let nanos = SystemTime::now()
294            .duration_since(UNIX_EPOCH)
295            .expect("system time after epoch")
296            .as_nanos();
297        std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
298    }
299}