use crate::{
journal::DownloadJournal,
manifest::{FleetBackupManifest, ManifestValidationError},
};
use serde::{Serialize, de::DeserializeOwned};
use std::{
fs::{self, File},
io,
path::{Path, PathBuf},
};
use thiserror::Error as ThisError;
const MANIFEST_FILE_NAME: &str = "fleet-backup-manifest.json";
const JOURNAL_FILE_NAME: &str = "download-journal.json";
#[derive(Clone, Debug)]
pub struct BackupLayout {
root: PathBuf,
}
impl BackupLayout {
#[must_use]
pub const fn new(root: PathBuf) -> Self {
Self { root }
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn manifest_path(&self) -> PathBuf {
self.root.join(MANIFEST_FILE_NAME)
}
#[must_use]
pub fn journal_path(&self) -> PathBuf {
self.root.join(JOURNAL_FILE_NAME)
}
pub fn write_manifest(&self, manifest: &FleetBackupManifest) -> Result<(), PersistenceError> {
manifest.validate()?;
write_json_atomic(&self.manifest_path(), manifest)
}
pub fn read_manifest(&self) -> Result<FleetBackupManifest, PersistenceError> {
let manifest = read_json(&self.manifest_path())?;
FleetBackupManifest::validate(&manifest)?;
Ok(manifest)
}
pub fn write_journal(&self, journal: &DownloadJournal) -> Result<(), PersistenceError> {
journal.validate()?;
write_json_atomic(&self.journal_path(), journal)
}
pub fn read_journal(&self) -> Result<DownloadJournal, PersistenceError> {
let journal = read_json(&self.journal_path())?;
DownloadJournal::validate(&journal)?;
Ok(journal)
}
}
#[derive(Debug, ThisError)]
pub enum PersistenceError {
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
InvalidManifest(#[from] ManifestValidationError),
#[error(transparent)]
InvalidJournal(#[from] crate::journal::JournalValidationError),
}
fn write_json_atomic<T>(path: &Path, value: &T) -> Result<(), PersistenceError>
where
T: Serialize,
{
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp_path = temp_path_for(path);
let mut file = File::create(&tmp_path)?;
serde_json::to_writer_pretty(&mut file, value)?;
file.sync_all()?;
drop(file);
fs::rename(&tmp_path, path)?;
if let Some(parent) = path.parent() {
File::open(parent)?.sync_all()?;
}
Ok(())
}
fn read_json<T>(path: &Path) -> Result<T, PersistenceError>
where
T: DeserializeOwned,
{
let file = File::open(path)?;
Ok(serde_json::from_reader(file)?)
}
fn temp_path_for(path: &Path) -> PathBuf {
let mut file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("canic-backup")
.to_string();
file_name.push_str(".tmp");
path.with_file_name(file_name)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
journal::{ArtifactJournalEntry, ArtifactState},
manifest::{
BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember,
FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
VerificationCheck, VerificationPlan,
},
};
use std::{
fs,
time::{SystemTime, UNIX_EPOCH},
};
const ROOT: &str = "aaaaa-aa";
const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[test]
fn manifest_round_trips_through_layout() {
let root = temp_dir("canic-backup-manifest-layout");
let layout = BackupLayout::new(root.clone());
let manifest = valid_manifest();
layout
.write_manifest(&manifest)
.expect("write manifest atomically");
let read = layout.read_manifest().expect("read manifest");
fs::remove_dir_all(root).expect("remove temp layout");
assert_eq!(read.backup_id, manifest.backup_id);
}
#[test]
fn journal_round_trips_through_layout() {
let root = temp_dir("canic-backup-journal-layout");
let layout = BackupLayout::new(root.clone());
let journal = valid_journal();
layout
.write_journal(&journal)
.expect("write journal atomically");
let read = layout.read_journal().expect("read journal");
fs::remove_dir_all(root).expect("remove temp layout");
assert_eq!(read.backup_id, journal.backup_id);
}
#[test]
fn invalid_manifest_is_not_written() {
let root = temp_dir("canic-backup-invalid-manifest");
let layout = BackupLayout::new(root.clone());
let mut manifest = valid_manifest();
manifest.fleet.discovery_topology_hash = "bad".to_string();
let err = layout
.write_manifest(&manifest)
.expect_err("invalid manifest should fail");
let manifest_path = layout.manifest_path();
fs::remove_dir_all(root).ok();
assert!(matches!(err, PersistenceError::InvalidManifest(_)));
assert!(!manifest_path.exists());
}
fn valid_manifest() -> FleetBackupManifest {
FleetBackupManifest {
manifest_version: 1,
backup_id: "fbk_test_001".to_string(),
created_at: "2026-04-10T12:00:00Z".to_string(),
tool: ToolMetadata {
name: "canic".to_string(),
version: "v1".to_string(),
},
source: SourceMetadata {
environment: "local".to_string(),
root_canister: ROOT.to_string(),
},
consistency: ConsistencySection {
mode: ConsistencyMode::CrashConsistent,
backup_units: vec![BackupUnit {
unit_id: "whole-fleet".to_string(),
kind: BackupUnitKind::WholeFleet,
roles: vec!["root".to_string()],
consistency_reason: None,
dependency_closure: Vec::new(),
topology_validation: "subtree-closed".to_string(),
quiescence_strategy: None,
}],
},
fleet: FleetSection {
topology_hash_algorithm: "sha256".to_string(),
topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
discovery_topology_hash: HASH.to_string(),
pre_snapshot_topology_hash: HASH.to_string(),
topology_hash: HASH.to_string(),
members: vec![FleetMember {
role: "root".to_string(),
canister_id: ROOT.to_string(),
parent_canister_id: None,
subnet_canister_id: Some(CHILD.to_string()),
controller_hint: Some(ROOT.to_string()),
identity_mode: IdentityMode::Fixed,
restore_group: 1,
verification_class: "basic".to_string(),
verification_checks: vec![VerificationCheck {
kind: "call".to_string(),
method: Some("canic_ready".to_string()),
roles: Vec::new(),
}],
source_snapshot: SourceSnapshot {
snapshot_id: "snap-root".to_string(),
module_hash: Some(HASH.to_string()),
wasm_hash: Some(HASH.to_string()),
code_version: Some("v0.30.0".to_string()),
artifact_path: "artifacts/root".to_string(),
checksum_algorithm: "sha256".to_string(),
},
}],
},
verification: VerificationPlan {
fleet_checks: Vec::new(),
member_checks: Vec::new(),
},
}
}
fn valid_journal() -> DownloadJournal {
DownloadJournal {
journal_version: 1,
backup_id: "fbk_test_001".to_string(),
artifacts: vec![ArtifactJournalEntry {
canister_id: ROOT.to_string(),
snapshot_id: "snap-root".to_string(),
state: ArtifactState::Durable,
temp_path: None,
artifact_path: "artifacts/root".to_string(),
checksum_algorithm: "sha256".to_string(),
checksum: Some(HASH.to_string()),
updated_at: "2026-04-10T12:00:00Z".to_string(),
}],
}
}
fn temp_dir(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time after epoch")
.as_nanos();
std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
}
}