canic_backup/persistence/
mod.rs1use 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#[derive(Clone, Debug)]
21pub struct BackupLayout {
22 root: PathBuf,
23}
24
25impl BackupLayout {
26 #[must_use]
28 pub const fn new(root: PathBuf) -> Self {
29 Self { root }
30 }
31
32 #[must_use]
34 pub fn root(&self) -> &Path {
35 &self.root
36 }
37
38 #[must_use]
40 pub fn manifest_path(&self) -> PathBuf {
41 self.root.join(MANIFEST_FILE_NAME)
42 }
43
44 #[must_use]
46 pub fn journal_path(&self) -> PathBuf {
47 self.root.join(JOURNAL_FILE_NAME)
48 }
49
50 pub fn write_manifest(&self, manifest: &FleetBackupManifest) -> Result<(), PersistenceError> {
52 manifest.validate()?;
53 write_json_atomic(&self.manifest_path(), manifest)
54 }
55
56 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 pub fn write_journal(&self, journal: &DownloadJournal) -> Result<(), PersistenceError> {
65 journal.validate()?;
66 write_json_atomic(&self.journal_path(), journal)
67 }
68
69 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#[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
96fn 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
120fn 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
129fn 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 #[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 #[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 #[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 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 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 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}