use crate::{
artifacts::{ArtifactChecksum, ArtifactChecksumError},
journal::{ArtifactState, DownloadJournal},
manifest::{FleetBackupManifest, ManifestValidationError},
};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::{
collections::BTreeSet,
fs::{self, File},
io,
path::{Component, 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)
}
pub fn verify_integrity(&self) -> Result<BackupIntegrityReport, PersistenceError> {
let manifest = self.read_manifest()?;
let journal = self.read_journal()?;
verify_layout_integrity(self, &manifest, &journal)
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
struct TopologyReceiptMismatch {
field: String,
manifest: String,
journal: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct BackupIntegrityReport {
pub backup_id: String,
pub verified: bool,
pub manifest_members: usize,
pub journal_artifacts: usize,
pub durable_artifacts: usize,
pub artifacts: Vec<ArtifactIntegrityReport>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ArtifactIntegrityReport {
pub canister_id: String,
pub snapshot_id: String,
pub artifact_path: String,
pub checksum: String,
}
#[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),
#[error(transparent)]
Checksum(#[from] ArtifactChecksumError),
#[error("manifest backup id {manifest} does not match journal backup id {journal}")]
BackupIdMismatch { manifest: String, journal: String },
#[error("journal artifact {canister_id} snapshot {snapshot_id} is not durable")]
NonDurableArtifact {
canister_id: String,
snapshot_id: String,
},
#[error("manifest member {canister_id} snapshot {snapshot_id} has no journal artifact")]
MissingJournalArtifact {
canister_id: String,
snapshot_id: String,
},
#[error("journal artifact {canister_id} snapshot {snapshot_id} is not declared in manifest")]
UnexpectedJournalArtifact {
canister_id: String,
snapshot_id: String,
},
#[error(
"manifest checksum for {canister_id} snapshot {snapshot_id} does not match journal checksum"
)]
ManifestJournalChecksumMismatch {
canister_id: String,
snapshot_id: String,
manifest: String,
journal: String,
},
#[error(
"manifest artifact path for {canister_id} snapshot {snapshot_id} does not match journal artifact path"
)]
ManifestJournalArtifactPathMismatch {
canister_id: String,
snapshot_id: String,
manifest: String,
journal: String,
},
#[error("manifest topology receipt {field} does not match journal topology receipt")]
ManifestJournalTopologyReceiptMismatch {
field: String,
manifest: String,
journal: Option<String>,
},
#[error("artifact path escapes backup root: {artifact_path}")]
ArtifactPathEscapesBackup { artifact_path: String },
#[error("artifact path does not exist: {0}")]
MissingArtifact(String),
}
fn verify_layout_integrity(
layout: &BackupLayout,
manifest: &FleetBackupManifest,
journal: &DownloadJournal,
) -> Result<BackupIntegrityReport, PersistenceError> {
if manifest.backup_id != journal.backup_id {
return Err(PersistenceError::BackupIdMismatch {
manifest: manifest.backup_id.clone(),
journal: journal.backup_id.clone(),
});
}
if let Some(mismatch) = topology_receipt_mismatches(manifest, journal)
.into_iter()
.next()
{
return Err(PersistenceError::ManifestJournalTopologyReceiptMismatch {
field: mismatch.field,
manifest: mismatch.manifest,
journal: mismatch.journal,
});
}
let expected_artifacts = manifest
.fleet
.members
.iter()
.map(|member| {
(
member.canister_id.as_str(),
member.source_snapshot.snapshot_id.as_str(),
)
})
.collect::<BTreeSet<_>>();
for entry in &journal.artifacts {
if !expected_artifacts.contains(&(entry.canister_id.as_str(), entry.snapshot_id.as_str())) {
return Err(PersistenceError::UnexpectedJournalArtifact {
canister_id: entry.canister_id.clone(),
snapshot_id: entry.snapshot_id.clone(),
});
}
}
let mut artifacts = Vec::with_capacity(journal.artifacts.len());
for member in &manifest.fleet.members {
let Some(entry) = journal.artifacts.iter().find(|entry| {
entry.canister_id == member.canister_id
&& entry.snapshot_id == member.source_snapshot.snapshot_id
}) else {
return Err(PersistenceError::MissingJournalArtifact {
canister_id: member.canister_id.clone(),
snapshot_id: member.source_snapshot.snapshot_id.clone(),
});
};
if entry.state != ArtifactState::Durable {
return Err(PersistenceError::NonDurableArtifact {
canister_id: entry.canister_id.clone(),
snapshot_id: entry.snapshot_id.clone(),
});
}
let Some(expected_hash) = entry.checksum.as_deref() else {
unreachable!("validated durable journals must include checksums");
};
if member.source_snapshot.artifact_path != entry.artifact_path {
return Err(PersistenceError::ManifestJournalArtifactPathMismatch {
canister_id: entry.canister_id.clone(),
snapshot_id: entry.snapshot_id.clone(),
manifest: member.source_snapshot.artifact_path.clone(),
journal: entry.artifact_path.clone(),
});
}
if let Some(manifest_hash) = member.source_snapshot.checksum.as_deref()
&& manifest_hash != expected_hash
{
return Err(PersistenceError::ManifestJournalChecksumMismatch {
canister_id: entry.canister_id.clone(),
snapshot_id: entry.snapshot_id.clone(),
manifest: manifest_hash.to_string(),
journal: expected_hash.to_string(),
});
}
let artifact_path = resolve_backup_artifact_path(layout.root(), &entry.artifact_path)
.ok_or_else(|| PersistenceError::ArtifactPathEscapesBackup {
artifact_path: entry.artifact_path.clone(),
})?;
if !artifact_path.exists() {
return Err(PersistenceError::MissingArtifact(
artifact_path.display().to_string(),
));
}
ArtifactChecksum::from_path(&artifact_path)?.verify(expected_hash)?;
artifacts.push(ArtifactIntegrityReport {
canister_id: entry.canister_id.clone(),
snapshot_id: entry.snapshot_id.clone(),
artifact_path: artifact_path.display().to_string(),
checksum: expected_hash.to_string(),
});
}
Ok(BackupIntegrityReport {
backup_id: manifest.backup_id.clone(),
verified: true,
manifest_members: manifest.fleet.members.len(),
journal_artifacts: journal.artifacts.len(),
durable_artifacts: artifacts.len(),
artifacts,
})
}
fn topology_receipt_mismatches(
manifest: &FleetBackupManifest,
journal: &DownloadJournal,
) -> Vec<TopologyReceiptMismatch> {
let mut mismatches = Vec::new();
record_topology_receipt_mismatch(
&mut mismatches,
"discovery_topology_hash",
&manifest.fleet.discovery_topology_hash,
journal.discovery_topology_hash.as_deref(),
);
record_topology_receipt_mismatch(
&mut mismatches,
"pre_snapshot_topology_hash",
&manifest.fleet.pre_snapshot_topology_hash,
journal.pre_snapshot_topology_hash.as_deref(),
);
mismatches
}
fn record_topology_receipt_mismatch(
mismatches: &mut Vec<TopologyReceiptMismatch>,
field: &str,
manifest: &str,
journal: Option<&str>,
) {
if journal == Some(manifest) {
return;
}
mismatches.push(TopologyReceiptMismatch {
field: field.to_string(),
manifest: manifest.to_string(),
journal: journal.map(ToString::to_string),
});
}
#[must_use]
pub fn resolve_backup_artifact_path(root: &Path, artifact_path: &str) -> Option<PathBuf> {
let path = PathBuf::from(artifact_path);
if path.is_absolute() {
return None;
}
let is_safe = path
.components()
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
if !is_safe {
return None;
}
Some(root.join(path))
}
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;