use crate::restore as cli_restore;
use canic_backup::{
journal::{DownloadOperationMetrics, JournalResumeReport},
manifest::{BackupUnitKind, ConsistencyMode, FleetBackupManifest},
persistence::{
BackupInspectionReport, BackupIntegrityReport, BackupLayout, BackupProvenanceReport,
PersistenceError,
},
restore::{
RestoreApplyJournal, RestoreMapping, RestorePlan, RestorePlanError, RestorePlanner,
RestoreStatus,
},
};
use serde::Serialize;
use serde_json::json;
use std::{
ffi::OsString,
fs,
io::{self, Write},
path::{Path, PathBuf},
};
use thiserror::Error as ThisError;
#[derive(Debug, ThisError)]
pub enum BackupCommandError {
#[error("{0}")]
Usage(&'static str),
#[error("missing required option {0}")]
MissingOption(&'static str),
#[error("unknown option {0}")]
UnknownOption(String),
#[error("option {0} requires a value")]
MissingValue(&'static str),
#[error(
"backup journal {backup_id} is incomplete: {pending_artifacts}/{total_artifacts} artifacts still require resume work"
)]
IncompleteJournal {
backup_id: String,
total_artifacts: usize,
pending_artifacts: usize,
},
#[error(
"backup inspection {backup_id} is not ready for verification: backup_id_matches={backup_id_matches}, topology_receipts_match={topology_receipts_match}, journal_complete={journal_complete}, topology_mismatches={topology_mismatches}, missing={missing_artifacts}, unexpected={unexpected_artifacts}, path_mismatches={path_mismatches}, checksum_mismatches={checksum_mismatches}"
)]
InspectionNotReady {
backup_id: String,
backup_id_matches: bool,
topology_receipts_match: bool,
journal_complete: bool,
topology_mismatches: usize,
missing_artifacts: usize,
unexpected_artifacts: usize,
path_mismatches: usize,
checksum_mismatches: usize,
},
#[error(
"backup provenance {backup_id} is not consistent: backup_id_matches={backup_id_matches}, topology_receipts_match={topology_receipts_match}, topology_mismatches={topology_mismatches}"
)]
ProvenanceNotConsistent {
backup_id: String,
backup_id_matches: bool,
topology_receipts_match: bool,
topology_mismatches: usize,
},
#[error("restore plan for backup {backup_id} is not restore-ready: reasons={reasons:?}")]
RestoreNotReady {
backup_id: String,
reasons: Vec<String>,
},
#[error("backup manifest {backup_id} is not design-v1 ready")]
DesignConformanceNotReady { backup_id: String },
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Persistence(#[from] PersistenceError),
#[error(transparent)]
RestorePlan(#[from] RestorePlanError),
#[error(transparent)]
RestoreCli(#[from] cli_restore::RestoreCommandError),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BackupPreflightOptions {
pub dir: PathBuf,
pub out_dir: PathBuf,
pub mapping: Option<PathBuf>,
pub require_design_v1: bool,
pub require_restore_ready: bool,
}
impl BackupPreflightOptions {
pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
where
I: IntoIterator<Item = OsString>,
{
let mut dir = None;
let mut out_dir = None;
let mut mapping = None;
let mut require_design_v1 = false;
let mut require_restore_ready = false;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let arg = arg
.into_string()
.map_err(|_| BackupCommandError::Usage(usage()))?;
match arg.as_str() {
"--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
"--out-dir" => out_dir = Some(PathBuf::from(next_value(&mut args, "--out-dir")?)),
"--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
"--require-design-v1" => require_design_v1 = true,
"--require-restore-ready" => require_restore_ready = true,
"--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
_ => return Err(BackupCommandError::UnknownOption(arg)),
}
}
Ok(Self {
dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
out_dir: out_dir.ok_or(BackupCommandError::MissingOption("--out-dir"))?,
mapping,
require_design_v1,
require_restore_ready,
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BackupSmokeOptions {
pub dir: PathBuf,
pub out_dir: PathBuf,
pub mapping: Option<PathBuf>,
pub dfx: String,
pub network: Option<String>,
pub require_design_v1: bool,
pub require_restore_ready: bool,
}
impl BackupSmokeOptions {
pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
where
I: IntoIterator<Item = OsString>,
{
let mut dir = None;
let mut out_dir = None;
let mut mapping = None;
let mut dfx = "dfx".to_string();
let mut network = None;
let mut require_design_v1 = false;
let mut require_restore_ready = false;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let arg = arg
.into_string()
.map_err(|_| BackupCommandError::Usage(usage()))?;
match arg.as_str() {
"--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
"--out-dir" => out_dir = Some(PathBuf::from(next_value(&mut args, "--out-dir")?)),
"--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
"--dfx" => dfx = next_value(&mut args, "--dfx")?,
"--network" => network = Some(next_value(&mut args, "--network")?),
"--require-design-v1" => require_design_v1 = true,
"--require-restore-ready" => require_restore_ready = true,
"--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
_ => return Err(BackupCommandError::UnknownOption(arg)),
}
}
Ok(Self {
dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
out_dir: out_dir.ok_or(BackupCommandError::MissingOption("--out-dir"))?,
mapping,
dfx,
network,
require_design_v1,
require_restore_ready,
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[expect(
clippy::struct_excessive_bools,
reason = "preflight reports intentionally mirror machine-readable JSON status flags"
)]
pub struct BackupPreflightReport {
pub status: String,
pub backup_id: String,
pub backup_dir: String,
pub source_environment: String,
pub source_root_canister: String,
pub topology_hash: String,
pub mapping_path: Option<String>,
pub journal_complete: bool,
pub journal_operation_metrics: DownloadOperationMetrics,
pub inspection_status: String,
pub provenance_status: String,
pub backup_id_status: String,
pub topology_receipts_status: String,
pub topology_mismatch_count: usize,
pub integrity_verified: bool,
pub manifest_design_v1_ready: bool,
pub manifest_members: usize,
pub backup_unit_count: usize,
pub restore_plan_members: usize,
pub restore_mapping_supplied: bool,
pub restore_all_sources_mapped: bool,
pub restore_fixed_members: usize,
pub restore_relocatable_members: usize,
pub restore_in_place_members: usize,
pub restore_mapped_members: usize,
pub restore_remapped_members: usize,
pub restore_ready: bool,
pub restore_readiness_reasons: Vec<String>,
pub restore_all_members_have_module_hash: bool,
pub restore_all_members_have_wasm_hash: bool,
pub restore_all_members_have_code_version: bool,
pub restore_all_members_have_checksum: bool,
pub restore_members_with_module_hash: usize,
pub restore_members_with_wasm_hash: usize,
pub restore_members_with_code_version: usize,
pub restore_members_with_checksum: usize,
pub restore_verification_required: bool,
pub restore_all_members_have_checks: bool,
pub restore_fleet_checks: usize,
pub restore_member_check_groups: usize,
pub restore_member_checks: usize,
pub restore_members_with_checks: usize,
pub restore_total_checks: usize,
pub restore_planned_snapshot_uploads: usize,
pub restore_planned_snapshot_loads: usize,
pub restore_planned_code_reinstalls: usize,
pub restore_planned_verification_checks: usize,
pub restore_planned_operations: usize,
pub restore_planned_phases: usize,
pub restore_phase_count: usize,
pub restore_dependency_free_members: usize,
pub restore_in_group_parent_edges: usize,
pub restore_cross_group_parent_edges: usize,
pub manifest_validation_path: String,
pub backup_status_path: String,
pub backup_inspection_path: String,
pub backup_provenance_path: String,
pub backup_integrity_path: String,
pub restore_plan_path: String,
pub restore_status_path: String,
pub preflight_summary_path: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct BackupSmokeReport {
pub status: String,
pub backup_id: String,
pub backup_dir: String,
pub out_dir: String,
pub preflight_dir: String,
pub preflight_summary_path: String,
pub restore_apply_dry_run_path: String,
pub restore_apply_journal_path: String,
pub restore_run_dry_run_path: String,
pub smoke_summary_path: String,
pub manifest_design_v1_ready: bool,
pub restore_ready: bool,
pub restore_readiness_reasons: Vec<String>,
pub restore_planned_operations: usize,
pub runner_preview_written: bool,
}
struct PreflightArtifactPaths {
manifest_validation: PathBuf,
backup_status: PathBuf,
backup_inspection: PathBuf,
backup_provenance: PathBuf,
backup_integrity: PathBuf,
restore_plan: PathBuf,
restore_status: PathBuf,
preflight_summary: PathBuf,
}
struct PreflightReportInput<'a> {
options: &'a BackupPreflightOptions,
manifest: &'a FleetBackupManifest,
status: &'a JournalResumeReport,
inspection: &'a BackupInspectionReport,
provenance: &'a BackupProvenanceReport,
integrity: &'a BackupIntegrityReport,
restore_plan: &'a RestorePlan,
paths: &'a PreflightArtifactPaths,
}
struct PreflightArtifactInput<'a> {
paths: &'a PreflightArtifactPaths,
manifest: &'a FleetBackupManifest,
status: &'a JournalResumeReport,
inspection: &'a BackupInspectionReport,
provenance: &'a BackupProvenanceReport,
integrity: &'a BackupIntegrityReport,
restore_plan: &'a RestorePlan,
restore_status: &'a RestoreStatus,
}
struct SmokeArtifactPaths {
preflight_dir: PathBuf,
restore_apply_dry_run: PathBuf,
restore_apply_journal: PathBuf,
restore_run_dry_run: PathBuf,
smoke_summary: PathBuf,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BackupInspectOptions {
pub dir: PathBuf,
pub out: Option<PathBuf>,
pub require_ready: bool,
}
impl BackupInspectOptions {
pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
where
I: IntoIterator<Item = OsString>,
{
let mut dir = None;
let mut out = None;
let mut require_ready = false;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let arg = arg
.into_string()
.map_err(|_| BackupCommandError::Usage(usage()))?;
match arg.as_str() {
"--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
"--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
"--require-ready" => require_ready = true,
"--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
_ => return Err(BackupCommandError::UnknownOption(arg)),
}
}
Ok(Self {
dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
out,
require_ready,
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BackupProvenanceOptions {
pub dir: PathBuf,
pub out: Option<PathBuf>,
pub require_consistent: bool,
}
impl BackupProvenanceOptions {
pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
where
I: IntoIterator<Item = OsString>,
{
let mut dir = None;
let mut out = None;
let mut require_consistent = false;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let arg = arg
.into_string()
.map_err(|_| BackupCommandError::Usage(usage()))?;
match arg.as_str() {
"--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
"--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
"--require-consistent" => require_consistent = true,
"--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
_ => return Err(BackupCommandError::UnknownOption(arg)),
}
}
Ok(Self {
dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
out,
require_consistent,
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BackupVerifyOptions {
pub dir: PathBuf,
pub out: Option<PathBuf>,
}
impl BackupVerifyOptions {
pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
where
I: IntoIterator<Item = OsString>,
{
let mut dir = None;
let mut out = None;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let arg = arg
.into_string()
.map_err(|_| BackupCommandError::Usage(usage()))?;
match arg.as_str() {
"--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
"--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
"--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
_ => return Err(BackupCommandError::UnknownOption(arg)),
}
}
Ok(Self {
dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
out,
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BackupStatusOptions {
pub dir: PathBuf,
pub out: Option<PathBuf>,
pub require_complete: bool,
}
impl BackupStatusOptions {
pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
where
I: IntoIterator<Item = OsString>,
{
let mut dir = None;
let mut out = None;
let mut require_complete = false;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let arg = arg
.into_string()
.map_err(|_| BackupCommandError::Usage(usage()))?;
match arg.as_str() {
"--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
"--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
"--require-complete" => require_complete = true,
"--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
_ => return Err(BackupCommandError::UnknownOption(arg)),
}
}
Ok(Self {
dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
out,
require_complete,
})
}
}
pub fn run<I>(args: I) -> Result<(), BackupCommandError>
where
I: IntoIterator<Item = OsString>,
{
let mut args = args.into_iter();
let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
return Err(BackupCommandError::Usage(usage()));
};
match command.as_str() {
"preflight" => {
let options = BackupPreflightOptions::parse(args)?;
backup_preflight(&options)?;
Ok(())
}
"smoke" => {
let options = BackupSmokeOptions::parse(args)?;
backup_smoke(&options)?;
Ok(())
}
"inspect" => {
let options = BackupInspectOptions::parse(args)?;
let report = inspect_backup(&options)?;
write_inspect_report(&options, &report)?;
enforce_inspection_requirements(&options, &report)?;
Ok(())
}
"provenance" => {
let options = BackupProvenanceOptions::parse(args)?;
let report = backup_provenance(&options)?;
write_provenance_report(&options, &report)?;
enforce_provenance_requirements(&options, &report)?;
Ok(())
}
"status" => {
let options = BackupStatusOptions::parse(args)?;
let report = backup_status(&options)?;
write_status_report(&options, &report)?;
enforce_status_requirements(&options, &report)?;
Ok(())
}
"verify" => {
let options = BackupVerifyOptions::parse(args)?;
let report = verify_backup(&options)?;
write_report(&options, &report)?;
Ok(())
}
"help" | "--help" | "-h" => {
println!("{}", usage());
Ok(())
}
_ => Err(BackupCommandError::UnknownOption(command)),
}
}
pub fn backup_preflight(
options: &BackupPreflightOptions,
) -> Result<BackupPreflightReport, BackupCommandError> {
fs::create_dir_all(&options.out_dir)?;
let layout = BackupLayout::new(options.dir.clone());
let manifest = layout.read_manifest()?;
let status = layout.read_journal()?.resume_report();
ensure_complete_status(&status)?;
let inspection = layout.inspect()?;
let provenance = layout.provenance()?;
let integrity = layout.verify_integrity()?;
let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
let restore_plan = RestorePlanner::plan(&manifest, mapping.as_ref())?;
let restore_status = RestoreStatus::from_plan(&restore_plan);
let paths = preflight_artifact_paths(&options.out_dir);
write_preflight_artifacts(PreflightArtifactInput {
paths: &paths,
manifest: &manifest,
status: &status,
inspection: &inspection,
provenance: &provenance,
integrity: &integrity,
restore_plan: &restore_plan,
restore_status: &restore_status,
})?;
let report = build_preflight_report(PreflightReportInput {
options,
manifest: &manifest,
status: &status,
inspection: &inspection,
provenance: &provenance,
integrity: &integrity,
restore_plan: &restore_plan,
paths: &paths,
});
write_json_value_file(&paths.preflight_summary, &preflight_summary_value(&report))?;
enforce_preflight_requirements(options, &report)?;
Ok(report)
}
pub fn backup_smoke(options: &BackupSmokeOptions) -> Result<BackupSmokeReport, BackupCommandError> {
fs::create_dir_all(&options.out_dir)?;
let paths = smoke_artifact_paths(&options.out_dir);
let preflight = backup_preflight(&BackupPreflightOptions {
dir: options.dir.clone(),
out_dir: paths.preflight_dir.clone(),
mapping: options.mapping.clone(),
require_design_v1: options.require_design_v1,
require_restore_ready: options.require_restore_ready,
})?;
let apply_options = smoke_restore_apply_options(options, &paths);
let dry_run = cli_restore::restore_apply_dry_run(&apply_options)?;
write_json_file(&paths.restore_apply_dry_run, &dry_run)?;
let journal = RestoreApplyJournal::from_dry_run(&dry_run);
write_json_file(&paths.restore_apply_journal, &journal)?;
let run_options = smoke_restore_run_options(options, &paths);
let runner_preview = cli_restore::restore_run_dry_run(&run_options)?;
write_json_file(&paths.restore_run_dry_run, &runner_preview)?;
let report = build_smoke_report(options, &paths, &preflight);
write_json_file(&paths.smoke_summary, &report)?;
Ok(report)
}
fn smoke_artifact_paths(out_dir: &Path) -> SmokeArtifactPaths {
SmokeArtifactPaths {
preflight_dir: out_dir.join("preflight"),
restore_apply_dry_run: out_dir.join("restore-apply-dry-run.json"),
restore_apply_journal: out_dir.join("restore-apply-journal.json"),
restore_run_dry_run: out_dir.join("restore-run-dry-run.json"),
smoke_summary: out_dir.join("smoke-summary.json"),
}
}
fn smoke_restore_apply_options(
options: &BackupSmokeOptions,
paths: &SmokeArtifactPaths,
) -> cli_restore::RestoreApplyOptions {
cli_restore::RestoreApplyOptions {
plan: paths.preflight_dir.join("restore-plan.json"),
status: Some(paths.preflight_dir.join("restore-status.json")),
backup_dir: Some(options.dir.clone()),
out: Some(paths.restore_apply_dry_run.clone()),
journal_out: Some(paths.restore_apply_journal.clone()),
dry_run: true,
}
}
fn smoke_restore_run_options(
options: &BackupSmokeOptions,
paths: &SmokeArtifactPaths,
) -> cli_restore::RestoreRunOptions {
cli_restore::RestoreRunOptions {
journal: paths.restore_apply_journal.clone(),
dfx: options.dfx.clone(),
network: options.network.clone(),
out: Some(paths.restore_run_dry_run.clone()),
dry_run: true,
execute: false,
unclaim_pending: false,
max_steps: None,
updated_at: None,
require_complete: false,
require_no_attention: false,
require_run_mode: None,
require_stopped_reason: None,
require_next_action: None,
require_executed_count: None,
require_receipt_count: None,
require_completed_receipt_count: None,
require_failed_receipt_count: None,
require_recovered_receipt_count: None,
require_receipt_updated_at: None,
require_state_updated_at: None,
require_batch_initial_ready_count: None,
require_batch_executed_count: None,
require_batch_remaining_ready_count: None,
require_batch_ready_delta: None,
require_batch_remaining_delta: None,
require_batch_stopped_by_max_steps: None,
require_remaining_count: None,
require_attention_count: None,
require_completion_basis_points: None,
require_no_pending_before: None,
}
}
fn build_smoke_report(
options: &BackupSmokeOptions,
paths: &SmokeArtifactPaths,
preflight: &BackupPreflightReport,
) -> BackupSmokeReport {
BackupSmokeReport {
status: "ready".to_string(),
backup_id: preflight.backup_id.clone(),
backup_dir: options.dir.display().to_string(),
out_dir: options.out_dir.display().to_string(),
preflight_dir: paths.preflight_dir.display().to_string(),
preflight_summary_path: paths
.preflight_dir
.join("preflight-summary.json")
.display()
.to_string(),
restore_apply_dry_run_path: paths.restore_apply_dry_run.display().to_string(),
restore_apply_journal_path: paths.restore_apply_journal.display().to_string(),
restore_run_dry_run_path: paths.restore_run_dry_run.display().to_string(),
smoke_summary_path: paths.smoke_summary.display().to_string(),
manifest_design_v1_ready: preflight.manifest_design_v1_ready,
restore_ready: preflight.restore_ready,
restore_readiness_reasons: preflight.restore_readiness_reasons.clone(),
restore_planned_operations: preflight.restore_planned_operations,
runner_preview_written: true,
}
}
fn enforce_preflight_requirements(
options: &BackupPreflightOptions,
report: &BackupPreflightReport,
) -> Result<(), BackupCommandError> {
if options.require_design_v1 && !report.manifest_design_v1_ready {
return Err(BackupCommandError::DesignConformanceNotReady {
backup_id: report.backup_id.clone(),
});
}
if !options.require_restore_ready || report.restore_ready {
return Ok(());
}
Err(BackupCommandError::RestoreNotReady {
backup_id: report.backup_id.clone(),
reasons: report.restore_readiness_reasons.clone(),
})
}
fn preflight_artifact_paths(out_dir: &Path) -> PreflightArtifactPaths {
PreflightArtifactPaths {
manifest_validation: out_dir.join("manifest-validation.json"),
backup_status: out_dir.join("backup-status.json"),
backup_inspection: out_dir.join("backup-inspection.json"),
backup_provenance: out_dir.join("backup-provenance.json"),
backup_integrity: out_dir.join("backup-integrity.json"),
restore_plan: out_dir.join("restore-plan.json"),
restore_status: out_dir.join("restore-status.json"),
preflight_summary: out_dir.join("preflight-summary.json"),
}
}
fn write_preflight_artifacts(input: PreflightArtifactInput<'_>) -> Result<(), BackupCommandError> {
write_json_value_file(
&input.paths.manifest_validation,
&manifest_validation_summary(input.manifest),
)?;
fs::write(
&input.paths.backup_status,
serde_json::to_vec_pretty(&input.status)?,
)?;
fs::write(
&input.paths.backup_inspection,
serde_json::to_vec_pretty(&input.inspection)?,
)?;
fs::write(
&input.paths.backup_provenance,
serde_json::to_vec_pretty(&input.provenance)?,
)?;
fs::write(
&input.paths.backup_integrity,
serde_json::to_vec_pretty(&input.integrity)?,
)?;
fs::write(
&input.paths.restore_plan,
serde_json::to_vec_pretty(&input.restore_plan)?,
)?;
fs::write(
&input.paths.restore_status,
serde_json::to_vec_pretty(&input.restore_status)?,
)?;
Ok(())
}
fn build_preflight_report(input: PreflightReportInput<'_>) -> BackupPreflightReport {
let identity = &input.restore_plan.identity_summary;
let snapshot = &input.restore_plan.snapshot_summary;
let verification = &input.restore_plan.verification_summary;
let operation = &input.restore_plan.operation_summary;
let ordering = &input.restore_plan.ordering_summary;
BackupPreflightReport {
status: "ready".to_string(),
backup_id: input.manifest.backup_id.clone(),
backup_dir: input.options.dir.display().to_string(),
source_environment: input.manifest.source.environment.clone(),
source_root_canister: input.manifest.source.root_canister.clone(),
topology_hash: input.manifest.fleet.topology_hash.clone(),
mapping_path: input
.options
.mapping
.as_ref()
.map(|path| path.display().to_string()),
journal_complete: input.status.is_complete,
journal_operation_metrics: input.status.operation_metrics.clone(),
inspection_status: readiness_status(input.inspection.ready_for_verify).to_string(),
provenance_status: consistency_status(
input.provenance.backup_id_matches && input.provenance.topology_receipts_match,
)
.to_string(),
backup_id_status: match_status(input.provenance.backup_id_matches).to_string(),
topology_receipts_status: match_status(input.provenance.topology_receipts_match)
.to_string(),
topology_mismatch_count: input.provenance.topology_receipt_mismatches.len(),
integrity_verified: input.integrity.verified,
manifest_design_v1_ready: input.manifest.design_conformance_report().design_v1_ready,
manifest_members: input.manifest.fleet.members.len(),
backup_unit_count: input.provenance.backup_unit_count,
restore_plan_members: input.restore_plan.member_count,
restore_mapping_supplied: identity.mapping_supplied,
restore_all_sources_mapped: identity.all_sources_mapped,
restore_fixed_members: identity.fixed_members,
restore_relocatable_members: identity.relocatable_members,
restore_in_place_members: identity.in_place_members,
restore_mapped_members: identity.mapped_members,
restore_remapped_members: identity.remapped_members,
restore_ready: input.restore_plan.readiness_summary.ready,
restore_readiness_reasons: input.restore_plan.readiness_summary.reasons.clone(),
restore_all_members_have_module_hash: snapshot.all_members_have_module_hash,
restore_all_members_have_wasm_hash: snapshot.all_members_have_wasm_hash,
restore_all_members_have_code_version: snapshot.all_members_have_code_version,
restore_all_members_have_checksum: snapshot.all_members_have_checksum,
restore_members_with_module_hash: snapshot.members_with_module_hash,
restore_members_with_wasm_hash: snapshot.members_with_wasm_hash,
restore_members_with_code_version: snapshot.members_with_code_version,
restore_members_with_checksum: snapshot.members_with_checksum,
restore_verification_required: verification.verification_required,
restore_all_members_have_checks: verification.all_members_have_checks,
restore_fleet_checks: verification.fleet_checks,
restore_member_check_groups: verification.member_check_groups,
restore_member_checks: verification.member_checks,
restore_members_with_checks: verification.members_with_checks,
restore_total_checks: verification.total_checks,
restore_planned_snapshot_uploads: operation
.effective_planned_snapshot_uploads(input.restore_plan.member_count),
restore_planned_snapshot_loads: operation.planned_snapshot_loads,
restore_planned_code_reinstalls: operation.planned_code_reinstalls,
restore_planned_verification_checks: operation.planned_verification_checks,
restore_planned_operations: operation
.effective_planned_operations(input.restore_plan.member_count),
restore_planned_phases: operation.planned_phases,
restore_phase_count: ordering.phase_count,
restore_dependency_free_members: ordering.dependency_free_members,
restore_in_group_parent_edges: ordering.in_group_parent_edges,
restore_cross_group_parent_edges: ordering.cross_group_parent_edges,
manifest_validation_path: input.paths.manifest_validation.display().to_string(),
backup_status_path: input.paths.backup_status.display().to_string(),
backup_inspection_path: input.paths.backup_inspection.display().to_string(),
backup_provenance_path: input.paths.backup_provenance.display().to_string(),
backup_integrity_path: input.paths.backup_integrity.display().to_string(),
restore_plan_path: input.paths.restore_plan.display().to_string(),
restore_status_path: input.paths.restore_status.display().to_string(),
preflight_summary_path: input.paths.preflight_summary.display().to_string(),
}
}
pub fn inspect_backup(
options: &BackupInspectOptions,
) -> Result<BackupInspectionReport, BackupCommandError> {
let layout = BackupLayout::new(options.dir.clone());
layout.inspect().map_err(BackupCommandError::from)
}
pub fn backup_provenance(
options: &BackupProvenanceOptions,
) -> Result<BackupProvenanceReport, BackupCommandError> {
let layout = BackupLayout::new(options.dir.clone());
layout.provenance().map_err(BackupCommandError::from)
}
fn enforce_provenance_requirements(
options: &BackupProvenanceOptions,
report: &BackupProvenanceReport,
) -> Result<(), BackupCommandError> {
if !options.require_consistent || (report.backup_id_matches && report.topology_receipts_match) {
return Ok(());
}
Err(BackupCommandError::ProvenanceNotConsistent {
backup_id: report.backup_id.clone(),
backup_id_matches: report.backup_id_matches,
topology_receipts_match: report.topology_receipts_match,
topology_mismatches: report.topology_receipt_mismatches.len(),
})
}
fn enforce_inspection_requirements(
options: &BackupInspectOptions,
report: &BackupInspectionReport,
) -> Result<(), BackupCommandError> {
if !options.require_ready || report.ready_for_verify {
return Ok(());
}
Err(BackupCommandError::InspectionNotReady {
backup_id: report.backup_id.clone(),
backup_id_matches: report.backup_id_matches,
topology_receipts_match: report.topology_receipt_mismatches.is_empty(),
journal_complete: report.journal_complete,
topology_mismatches: report.topology_receipt_mismatches.len(),
missing_artifacts: report.missing_journal_artifacts.len(),
unexpected_artifacts: report.unexpected_journal_artifacts.len(),
path_mismatches: report.path_mismatches.len(),
checksum_mismatches: report.checksum_mismatches.len(),
})
}
pub fn backup_status(
options: &BackupStatusOptions,
) -> Result<JournalResumeReport, BackupCommandError> {
let layout = BackupLayout::new(options.dir.clone());
let journal = layout.read_journal()?;
Ok(journal.resume_report())
}
fn ensure_complete_status(report: &JournalResumeReport) -> Result<(), BackupCommandError> {
if report.is_complete {
return Ok(());
}
Err(BackupCommandError::IncompleteJournal {
backup_id: report.backup_id.clone(),
total_artifacts: report.total_artifacts,
pending_artifacts: report.pending_artifacts,
})
}
fn enforce_status_requirements(
options: &BackupStatusOptions,
report: &JournalResumeReport,
) -> Result<(), BackupCommandError> {
if !options.require_complete {
return Ok(());
}
ensure_complete_status(report)
}
pub fn verify_backup(
options: &BackupVerifyOptions,
) -> Result<BackupIntegrityReport, BackupCommandError> {
let layout = BackupLayout::new(options.dir.clone());
layout.verify_integrity().map_err(BackupCommandError::from)
}
fn write_status_report(
options: &BackupStatusOptions,
report: &JournalResumeReport,
) -> Result<(), BackupCommandError> {
if let Some(path) = &options.out {
let data = serde_json::to_vec_pretty(report)?;
fs::write(path, data)?;
return Ok(());
}
let stdout = io::stdout();
let mut handle = stdout.lock();
serde_json::to_writer_pretty(&mut handle, report)?;
writeln!(handle)?;
Ok(())
}
fn write_inspect_report(
options: &BackupInspectOptions,
report: &BackupInspectionReport,
) -> Result<(), BackupCommandError> {
if let Some(path) = &options.out {
let data = serde_json::to_vec_pretty(report)?;
fs::write(path, data)?;
return Ok(());
}
let stdout = io::stdout();
let mut handle = stdout.lock();
serde_json::to_writer_pretty(&mut handle, report)?;
writeln!(handle)?;
Ok(())
}
fn write_provenance_report(
options: &BackupProvenanceOptions,
report: &BackupProvenanceReport,
) -> Result<(), BackupCommandError> {
if let Some(path) = &options.out {
let data = serde_json::to_vec_pretty(report)?;
fs::write(path, data)?;
return Ok(());
}
let stdout = io::stdout();
let mut handle = stdout.lock();
serde_json::to_writer_pretty(&mut handle, report)?;
writeln!(handle)?;
Ok(())
}
fn write_report(
options: &BackupVerifyOptions,
report: &BackupIntegrityReport,
) -> Result<(), BackupCommandError> {
if let Some(path) = &options.out {
let data = serde_json::to_vec_pretty(report)?;
fs::write(path, data)?;
return Ok(());
}
let stdout = io::stdout();
let mut handle = stdout.lock();
serde_json::to_writer_pretty(&mut handle, report)?;
writeln!(handle)?;
Ok(())
}
fn write_json_file<T>(path: &PathBuf, value: &T) -> Result<(), BackupCommandError>
where
T: Serialize,
{
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let data = serde_json::to_vec_pretty(value)?;
fs::write(path, data)?;
Ok(())
}
fn write_json_value_file(
path: &PathBuf,
value: &serde_json::Value,
) -> Result<(), BackupCommandError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let data = serde_json::to_vec_pretty(value)?;
fs::write(path, data)?;
Ok(())
}
fn preflight_summary_value(report: &BackupPreflightReport) -> serde_json::Value {
let mut summary = serde_json::Map::new();
insert_preflight_source_summary(&mut summary, report);
insert_preflight_restore_summary(&mut summary, report);
insert_preflight_report_paths(&mut summary, report);
serde_json::Value::Object(summary)
}
fn insert_summary_value(
summary: &mut serde_json::Map<String, serde_json::Value>,
key: &'static str,
value: serde_json::Value,
) {
summary.insert(key.to_string(), value);
}
fn insert_preflight_source_summary(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(summary, "status", json!(report.status));
insert_summary_value(summary, "backup_id", json!(report.backup_id));
insert_summary_value(summary, "backup_dir", json!(report.backup_dir));
insert_summary_value(
summary,
"source_environment",
json!(report.source_environment),
);
insert_summary_value(
summary,
"source_root_canister",
json!(report.source_root_canister),
);
insert_summary_value(summary, "topology_hash", json!(report.topology_hash));
insert_summary_value(summary, "mapping_path", json!(report.mapping_path));
insert_summary_value(summary, "journal_complete", json!(report.journal_complete));
insert_summary_value(
summary,
"journal_operation_metrics",
json!(report.journal_operation_metrics),
);
insert_summary_value(
summary,
"inspection_status",
json!(report.inspection_status),
);
insert_summary_value(
summary,
"provenance_status",
json!(report.provenance_status),
);
insert_summary_value(summary, "backup_id_status", json!(report.backup_id_status));
insert_summary_value(
summary,
"topology_receipts_status",
json!(report.topology_receipts_status),
);
insert_summary_value(
summary,
"topology_mismatch_count",
json!(report.topology_mismatch_count),
);
insert_summary_value(
summary,
"integrity_verified",
json!(report.integrity_verified),
);
insert_summary_value(
summary,
"manifest_design_v1_ready",
json!(report.manifest_design_v1_ready),
);
insert_summary_value(summary, "manifest_members", json!(report.manifest_members));
insert_summary_value(
summary,
"backup_unit_count",
json!(report.backup_unit_count),
);
}
fn insert_preflight_restore_summary(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(
summary,
"restore_plan_members",
json!(report.restore_plan_members),
);
insert_summary_value(
summary,
"restore_mapping_supplied",
json!(report.restore_mapping_supplied),
);
insert_summary_value(
summary,
"restore_all_sources_mapped",
json!(report.restore_all_sources_mapped),
);
insert_preflight_restore_identity_summary(summary, report);
insert_preflight_restore_readiness_summary(summary, report);
insert_preflight_restore_snapshot_summary(summary, report);
insert_preflight_restore_verification_summary(summary, report);
insert_preflight_restore_operation_summary(summary, report);
insert_preflight_restore_ordering_summary(summary, report);
}
fn insert_preflight_restore_identity_summary(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(
summary,
"restore_fixed_members",
json!(report.restore_fixed_members),
);
insert_summary_value(
summary,
"restore_relocatable_members",
json!(report.restore_relocatable_members),
);
insert_summary_value(
summary,
"restore_in_place_members",
json!(report.restore_in_place_members),
);
insert_summary_value(
summary,
"restore_mapped_members",
json!(report.restore_mapped_members),
);
insert_summary_value(
summary,
"restore_remapped_members",
json!(report.restore_remapped_members),
);
}
fn insert_preflight_restore_readiness_summary(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(summary, "restore_ready", json!(report.restore_ready));
insert_summary_value(
summary,
"restore_readiness_reasons",
json!(report.restore_readiness_reasons),
);
}
fn insert_preflight_restore_snapshot_summary(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(
summary,
"restore_all_members_have_module_hash",
json!(report.restore_all_members_have_module_hash),
);
insert_summary_value(
summary,
"restore_all_members_have_wasm_hash",
json!(report.restore_all_members_have_wasm_hash),
);
insert_summary_value(
summary,
"restore_all_members_have_code_version",
json!(report.restore_all_members_have_code_version),
);
insert_summary_value(
summary,
"restore_all_members_have_checksum",
json!(report.restore_all_members_have_checksum),
);
insert_summary_value(
summary,
"restore_members_with_module_hash",
json!(report.restore_members_with_module_hash),
);
insert_summary_value(
summary,
"restore_members_with_wasm_hash",
json!(report.restore_members_with_wasm_hash),
);
insert_summary_value(
summary,
"restore_members_with_code_version",
json!(report.restore_members_with_code_version),
);
insert_summary_value(
summary,
"restore_members_with_checksum",
json!(report.restore_members_with_checksum),
);
}
fn insert_preflight_restore_verification_summary(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(
summary,
"restore_verification_required",
json!(report.restore_verification_required),
);
insert_summary_value(
summary,
"restore_all_members_have_checks",
json!(report.restore_all_members_have_checks),
);
insert_summary_value(
summary,
"restore_fleet_checks",
json!(report.restore_fleet_checks),
);
insert_summary_value(
summary,
"restore_member_check_groups",
json!(report.restore_member_check_groups),
);
insert_summary_value(
summary,
"restore_member_checks",
json!(report.restore_member_checks),
);
insert_summary_value(
summary,
"restore_members_with_checks",
json!(report.restore_members_with_checks),
);
insert_summary_value(
summary,
"restore_total_checks",
json!(report.restore_total_checks),
);
}
fn insert_preflight_restore_operation_summary(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(
summary,
"restore_planned_snapshot_uploads",
json!(report.restore_planned_snapshot_uploads),
);
insert_summary_value(
summary,
"restore_planned_snapshot_loads",
json!(report.restore_planned_snapshot_loads),
);
insert_summary_value(
summary,
"restore_planned_code_reinstalls",
json!(report.restore_planned_code_reinstalls),
);
insert_summary_value(
summary,
"restore_planned_verification_checks",
json!(report.restore_planned_verification_checks),
);
insert_summary_value(
summary,
"restore_planned_operations",
json!(report.restore_planned_operations),
);
insert_summary_value(
summary,
"restore_planned_phases",
json!(report.restore_planned_phases),
);
}
fn insert_preflight_restore_ordering_summary(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(
summary,
"restore_phase_count",
json!(report.restore_phase_count),
);
insert_summary_value(
summary,
"restore_dependency_free_members",
json!(report.restore_dependency_free_members),
);
insert_summary_value(
summary,
"restore_in_group_parent_edges",
json!(report.restore_in_group_parent_edges),
);
insert_summary_value(
summary,
"restore_cross_group_parent_edges",
json!(report.restore_cross_group_parent_edges),
);
}
fn insert_preflight_report_paths(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(
summary,
"manifest_validation_path",
json!(report.manifest_validation_path),
);
insert_summary_value(
summary,
"backup_status_path",
json!(report.backup_status_path),
);
insert_summary_value(
summary,
"backup_inspection_path",
json!(report.backup_inspection_path),
);
insert_summary_value(
summary,
"backup_provenance_path",
json!(report.backup_provenance_path),
);
insert_summary_value(
summary,
"backup_integrity_path",
json!(report.backup_integrity_path),
);
insert_summary_value(
summary,
"restore_plan_path",
json!(report.restore_plan_path),
);
insert_summary_value(
summary,
"restore_status_path",
json!(report.restore_status_path),
);
insert_summary_value(
summary,
"preflight_summary_path",
json!(report.preflight_summary_path),
);
}
fn manifest_validation_summary(manifest: &FleetBackupManifest) -> serde_json::Value {
json!({
"status": "valid",
"backup_id": manifest.backup_id,
"members": manifest.fleet.members.len(),
"backup_unit_count": manifest.consistency.backup_units.len(),
"consistency_mode": consistency_mode_name(&manifest.consistency.mode),
"topology_hash": manifest.fleet.topology_hash,
"topology_hash_algorithm": manifest.fleet.topology_hash_algorithm,
"topology_hash_input": manifest.fleet.topology_hash_input,
"topology_validation_status": "validated",
"design_conformance": manifest.design_conformance_report(),
"backup_unit_kinds": backup_unit_kind_counts(manifest),
"backup_units": manifest
.consistency
.backup_units
.iter()
.map(|unit| json!({
"unit_id": unit.unit_id,
"kind": backup_unit_kind_name(&unit.kind),
"role_count": unit.roles.len(),
"dependency_count": unit.dependency_closure.len(),
"topology_validation": unit.topology_validation,
}))
.collect::<Vec<_>>(),
})
}
fn backup_unit_kind_counts(manifest: &FleetBackupManifest) -> serde_json::Value {
let mut whole_fleet = 0;
let mut control_plane_subset = 0;
let mut subtree_rooted = 0;
let mut flat = 0;
for unit in &manifest.consistency.backup_units {
match &unit.kind {
BackupUnitKind::WholeFleet => whole_fleet += 1,
BackupUnitKind::ControlPlaneSubset => control_plane_subset += 1,
BackupUnitKind::SubtreeRooted => subtree_rooted += 1,
BackupUnitKind::Flat => flat += 1,
}
}
json!({
"whole_fleet": whole_fleet,
"control_plane_subset": control_plane_subset,
"subtree_rooted": subtree_rooted,
"flat": flat,
})
}
const fn consistency_mode_name(mode: &ConsistencyMode) -> &'static str {
match mode {
ConsistencyMode::CrashConsistent => "crash-consistent",
ConsistencyMode::QuiescedUnit => "quiesced-unit",
}
}
const fn backup_unit_kind_name(kind: &BackupUnitKind) -> &'static str {
match kind {
BackupUnitKind::WholeFleet => "whole-fleet",
BackupUnitKind::ControlPlaneSubset => "control-plane-subset",
BackupUnitKind::SubtreeRooted => "subtree-rooted",
BackupUnitKind::Flat => "flat",
}
}
const fn readiness_status(ready: bool) -> &'static str {
if ready { "ready" } else { "not-ready" }
}
const fn consistency_status(consistent: bool) -> &'static str {
if consistent {
"consistent"
} else {
"inconsistent"
}
}
const fn match_status(matches: bool) -> &'static str {
if matches { "matched" } else { "mismatched" }
}
fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, BackupCommandError> {
let data = fs::read_to_string(path)?;
serde_json::from_str(&data).map_err(BackupCommandError::from)
}
fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, BackupCommandError>
where
I: Iterator<Item = OsString>,
{
args.next()
.and_then(|value| value.into_string().ok())
.ok_or(BackupCommandError::MissingValue(option))
}
const fn usage() -> &'static str {
"usage: canic backup <command> [<args>]\n\ncommands:\n smoke Run the post-capture no-mutation smoke path.\n preflight Write the standard validation, integrity, plan, and status bundle.\n inspect Check manifest and journal agreement without reading artifact bytes.\n provenance Summarize backup source, topology, and artifact provenance.\n status Summarize resumable download journal state.\n verify Verify layout and durable artifact checksums."
}
#[cfg(test)]
mod tests {
use super::*;
use canic_backup::{
artifacts::ArtifactChecksum,
journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
manifest::{
BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetBackupManifest,
FleetMember, FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
VerificationCheck, VerificationPlan,
},
restore::RestoreMemberState,
};
use std::{
fs,
path::Path,
time::{SystemTime, UNIX_EPOCH},
};
const ROOT: &str = "aaaaa-aa";
const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[test]
fn parses_backup_preflight_options() {
let options = BackupPreflightOptions::parse([
OsString::from("--dir"),
OsString::from("backups/run"),
OsString::from("--out-dir"),
OsString::from("reports/run"),
OsString::from("--mapping"),
OsString::from("mapping.json"),
OsString::from("--require-design-v1"),
OsString::from("--require-restore-ready"),
])
.expect("parse options");
assert_eq!(options.dir, PathBuf::from("backups/run"));
assert_eq!(options.out_dir, PathBuf::from("reports/run"));
assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
assert!(options.require_design_v1);
assert!(options.require_restore_ready);
}
#[test]
fn parses_backup_smoke_options() {
let options = BackupSmokeOptions::parse([
OsString::from("--dir"),
OsString::from("backups/run"),
OsString::from("--out-dir"),
OsString::from("smoke/run"),
OsString::from("--mapping"),
OsString::from("mapping.json"),
OsString::from("--dfx"),
OsString::from("/bin/true"),
OsString::from("--network"),
OsString::from("local"),
OsString::from("--require-design-v1"),
OsString::from("--require-restore-ready"),
])
.expect("parse options");
assert_eq!(options.dir, PathBuf::from("backups/run"));
assert_eq!(options.out_dir, PathBuf::from("smoke/run"));
assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
assert_eq!(options.dfx, "/bin/true");
assert_eq!(options.network, Some("local".to_string()));
assert!(options.require_design_v1);
assert!(options.require_restore_ready);
}
#[test]
fn backup_usage_lists_commands_without_nested_flag_dump() {
let text = usage();
assert!(text.contains("usage: canic backup <command> [<args>]"));
assert!(text.contains("smoke"));
assert!(text.contains("preflight"));
assert!(text.contains("verify"));
assert!(!text.contains("--require-restore-ready"));
assert!(!text.contains("--require-design-v1"));
}
#[test]
fn backup_preflight_writes_standard_reports() {
let root = temp_dir("canic-cli-backup-preflight");
let out_dir = root.join("reports");
let backup_dir = root.join("backup");
let layout = BackupLayout::new(backup_dir.clone());
let checksum = write_artifact(&backup_dir, b"root artifact");
layout
.write_manifest(&valid_manifest())
.expect("write manifest");
layout
.write_journal(&journal_with_checksum(checksum.hash))
.expect("write journal");
let options = BackupPreflightOptions {
dir: backup_dir,
out_dir: out_dir.clone(),
mapping: None,
require_design_v1: false,
require_restore_ready: false,
};
let report = backup_preflight(&options).expect("run preflight");
assert_eq!(report.status, "ready");
assert_eq!(report.backup_id, "backup-test");
assert_eq!(report.source_environment, "local");
assert_eq!(report.source_root_canister, ROOT);
assert_eq!(report.topology_hash, HASH);
assert_eq!(report.mapping_path, None);
assert!(report.journal_complete);
assert_eq!(
report.journal_operation_metrics,
DownloadOperationMetrics::default()
);
assert_eq!(report.inspection_status, "ready");
assert_eq!(report.provenance_status, "consistent");
assert_eq!(report.backup_id_status, "matched");
assert_eq!(report.topology_receipts_status, "matched");
assert_eq!(report.topology_mismatch_count, 0);
assert!(report.integrity_verified);
assert!(!report.manifest_design_v1_ready);
assert_eq!(report.manifest_members, 1);
assert_eq!(report.backup_unit_count, 1);
assert_eq!(report.restore_plan_members, 1);
assert!(!report.restore_mapping_supplied);
assert!(!report.restore_all_sources_mapped);
assert_preflight_report_restore_counts(&report);
assert!(out_dir.join("manifest-validation.json").exists());
assert!(out_dir.join("backup-status.json").exists());
assert!(out_dir.join("backup-inspection.json").exists());
assert!(out_dir.join("backup-provenance.json").exists());
assert!(out_dir.join("backup-integrity.json").exists());
assert!(out_dir.join("restore-plan.json").exists());
assert!(out_dir.join("restore-status.json").exists());
assert!(out_dir.join("preflight-summary.json").exists());
let summary: serde_json::Value = serde_json::from_slice(
&fs::read(out_dir.join("preflight-summary.json")).expect("read summary"),
)
.expect("decode summary");
let manifest_validation: serde_json::Value = serde_json::from_slice(
&fs::read(out_dir.join("manifest-validation.json")).expect("read manifest summary"),
)
.expect("decode manifest summary");
let restore_status: RestoreStatus = serde_json::from_slice(
&fs::read(out_dir.join("restore-status.json")).expect("read restore status"),
)
.expect("decode restore status");
fs::remove_dir_all(root).expect("remove temp root");
assert_preflight_summary_matches_report(&summary, &report);
assert_eq!(restore_status.status_version, 1);
assert_eq!(restore_status.backup_id.as_str(), report.backup_id.as_str());
assert_eq!(restore_status.member_count, report.restore_plan_members);
assert_eq!(restore_status.phase_count, report.restore_phase_count);
assert_eq!(
restore_status.phases[0].members[0].state,
RestoreMemberState::Planned
);
assert_eq!(manifest_validation["backup_unit_count"], 1);
assert_eq!(manifest_validation["consistency_mode"], "crash-consistent");
assert_eq!(
manifest_validation["topology_validation_status"],
"validated"
);
assert_eq!(
manifest_validation["backup_unit_kinds"]["subtree_rooted"],
1
);
assert_eq!(
manifest_validation["backup_units"][0]["kind"],
"subtree-rooted"
);
assert_eq!(
manifest_validation["design_conformance"]["design_v1_ready"],
false
);
}
#[test]
fn backup_preflight_require_restore_ready_writes_reports_then_fails() {
let root = temp_dir("canic-cli-backup-preflight-require-restore-ready");
let out_dir = root.join("reports");
let backup_dir = root.join("backup");
let layout = BackupLayout::new(backup_dir.clone());
let checksum = write_artifact(&backup_dir, b"root artifact");
layout
.write_manifest(&valid_manifest())
.expect("write manifest");
layout
.write_journal(&journal_with_checksum(checksum.hash))
.expect("write journal");
let options = BackupPreflightOptions {
dir: backup_dir,
out_dir: out_dir.clone(),
mapping: None,
require_design_v1: false,
require_restore_ready: true,
};
let err = backup_preflight(&options).expect_err("restore readiness should be enforced");
assert!(out_dir.join("preflight-summary.json").exists());
assert!(out_dir.join("restore-status.json").exists());
let summary: serde_json::Value = serde_json::from_slice(
&fs::read(out_dir.join("preflight-summary.json")).expect("read summary"),
)
.expect("decode summary");
fs::remove_dir_all(root).expect("remove temp root");
assert_eq!(summary["restore_ready"], false);
assert!(matches!(
err,
BackupCommandError::RestoreNotReady {
reasons,
..
} if reasons == [
"missing-module-hash",
"missing-wasm-hash",
"missing-snapshot-checksum"
]
));
}
#[test]
fn backup_preflight_require_design_v1_writes_reports_then_fails() {
let root = temp_dir("canic-cli-backup-preflight-require-design-v1");
let out_dir = root.join("reports");
let backup_dir = root.join("backup");
let layout = BackupLayout::new(backup_dir.clone());
let checksum = write_artifact(&backup_dir, b"root artifact");
layout
.write_manifest(&valid_manifest())
.expect("write manifest");
layout
.write_journal(&journal_with_checksum(checksum.hash))
.expect("write journal");
let options = BackupPreflightOptions {
dir: backup_dir,
out_dir: out_dir.clone(),
mapping: None,
require_design_v1: true,
require_restore_ready: false,
};
let err = backup_preflight(&options).expect_err("design-v1 readiness should be enforced");
assert!(out_dir.join("preflight-summary.json").exists());
assert!(out_dir.join("manifest-validation.json").exists());
let summary: serde_json::Value = serde_json::from_slice(
&fs::read(out_dir.join("preflight-summary.json")).expect("read summary"),
)
.expect("decode summary");
let manifest_validation: serde_json::Value = serde_json::from_slice(
&fs::read(out_dir.join("manifest-validation.json")).expect("read manifest summary"),
)
.expect("decode manifest summary");
fs::remove_dir_all(root).expect("remove temp root");
assert_eq!(summary["manifest_design_v1_ready"], false);
assert_eq!(
manifest_validation["design_conformance"]["design_v1_ready"],
false
);
assert!(matches!(
err,
BackupCommandError::DesignConformanceNotReady { .. }
));
}
#[test]
fn backup_preflight_require_restore_ready_accepts_ready_report() {
let root = temp_dir("canic-cli-backup-preflight-ready");
let out_dir = root.join("reports");
let backup_dir = root.join("backup");
let layout = BackupLayout::new(backup_dir.clone());
let checksum = write_artifact(&backup_dir, b"root artifact");
layout
.write_manifest(&restore_ready_manifest(&checksum.hash))
.expect("write manifest");
layout
.write_journal(&journal_with_checksum(checksum.hash))
.expect("write journal");
let options = BackupPreflightOptions {
dir: backup_dir,
out_dir: out_dir.clone(),
mapping: None,
require_design_v1: true,
require_restore_ready: true,
};
let report = backup_preflight(&options).expect("ready preflight should pass");
let summary: serde_json::Value = serde_json::from_slice(
&fs::read(out_dir.join("preflight-summary.json")).expect("read summary"),
)
.expect("decode summary");
let manifest_validation: serde_json::Value = serde_json::from_slice(
&fs::read(out_dir.join("manifest-validation.json")).expect("read manifest summary"),
)
.expect("decode manifest summary");
let restore_plan: RestorePlan = serde_json::from_slice(
&fs::read(out_dir.join("restore-plan.json")).expect("read plan"),
)
.expect("decode restore plan");
fs::remove_dir_all(root).expect("remove temp root");
assert!(report.manifest_design_v1_ready);
assert!(report.restore_ready);
assert!(report.restore_readiness_reasons.is_empty());
assert_eq!(summary["restore_ready"], true);
assert_eq!(summary["manifest_design_v1_ready"], true);
assert_eq!(
manifest_validation["design_conformance"]["design_v1_ready"],
true
);
assert!(
restore_plan
.design_conformance
.as_ref()
.expect("restore plan should include design conformance")
.design_v1_ready
);
assert_eq!(summary["restore_readiness_reasons"], json!([]));
assert_eq!(
summary["restore_status_path"],
out_dir.join("restore-status.json").display().to_string()
);
}
#[test]
fn backup_smoke_writes_release_bundle() {
let root = temp_dir("canic-cli-backup-smoke");
let out_dir = root.join("smoke");
let backup_dir = root.join("backup");
let layout = BackupLayout::new(backup_dir.clone());
let checksum = write_artifact(&backup_dir, b"root artifact");
layout
.write_manifest(&restore_ready_manifest(&checksum.hash))
.expect("write manifest");
layout
.write_journal(&journal_with_checksum(checksum.hash))
.expect("write journal");
let options = BackupSmokeOptions {
dir: backup_dir,
out_dir: out_dir.clone(),
mapping: None,
dfx: "/bin/true".to_string(),
network: Some("local".to_string()),
require_design_v1: true,
require_restore_ready: true,
};
let report = backup_smoke(&options).expect("smoke should pass");
let summary: serde_json::Value = serde_json::from_slice(
&fs::read(out_dir.join("smoke-summary.json")).expect("read smoke summary"),
)
.expect("decode smoke summary");
let runner_preview: serde_json::Value = serde_json::from_slice(
&fs::read(out_dir.join("restore-run-dry-run.json")).expect("read runner preview"),
)
.expect("decode runner preview");
assert_eq!(report.status, "ready");
assert_eq!(report.backup_id, "backup-test");
assert!(report.manifest_design_v1_ready);
assert!(report.restore_ready);
assert!(report.runner_preview_written);
assert!(out_dir.join("preflight/preflight-summary.json").exists());
assert!(out_dir.join("preflight/restore-plan.json").exists());
assert!(out_dir.join("preflight/restore-status.json").exists());
assert!(out_dir.join("restore-apply-dry-run.json").exists());
assert!(out_dir.join("restore-apply-journal.json").exists());
assert!(out_dir.join("restore-run-dry-run.json").exists());
assert_eq!(summary["status"], "ready");
assert_eq!(summary["restore_ready"], true);
assert_eq!(summary["manifest_design_v1_ready"], true);
assert_eq!(summary["runner_preview_written"], true);
assert_eq!(runner_preview["run_mode"], "dry-run");
assert_eq!(runner_preview["dry_run"], true);
assert_eq!(runner_preview["operation_receipt_count"], 0);
fs::remove_dir_all(root).expect("remove temp root");
}
fn assert_preflight_report_restore_counts(report: &BackupPreflightReport) {
assert_eq!(report.restore_fixed_members, 1);
assert_eq!(report.restore_relocatable_members, 0);
assert_eq!(report.restore_in_place_members, 1);
assert_eq!(report.restore_mapped_members, 0);
assert_eq!(report.restore_remapped_members, 0);
assert!(!report.restore_ready);
assert_eq!(
report.restore_readiness_reasons,
[
"missing-module-hash",
"missing-wasm-hash",
"missing-snapshot-checksum"
]
);
assert!(!report.restore_all_members_have_module_hash);
assert!(!report.restore_all_members_have_wasm_hash);
assert!(report.restore_all_members_have_code_version);
assert!(!report.restore_all_members_have_checksum);
assert_eq!(report.restore_members_with_module_hash, 0);
assert_eq!(report.restore_members_with_wasm_hash, 0);
assert_eq!(report.restore_members_with_code_version, 1);
assert_eq!(report.restore_members_with_checksum, 0);
assert!(report.restore_verification_required);
assert!(report.restore_all_members_have_checks);
assert_eq!(report.restore_fleet_checks, 0);
assert_eq!(report.restore_member_check_groups, 0);
assert_eq!(report.restore_member_checks, 1);
assert_eq!(report.restore_members_with_checks, 1);
assert_eq!(report.restore_total_checks, 1);
assert_eq!(report.restore_planned_snapshot_uploads, 1);
assert_eq!(report.restore_planned_snapshot_loads, 1);
assert_eq!(report.restore_planned_code_reinstalls, 1);
assert_eq!(report.restore_planned_verification_checks, 1);
assert_eq!(report.restore_planned_operations, 4);
assert_eq!(report.restore_planned_phases, 1);
assert_eq!(report.restore_phase_count, 1);
assert_eq!(report.restore_dependency_free_members, 1);
assert_eq!(report.restore_in_group_parent_edges, 0);
assert_eq!(report.restore_cross_group_parent_edges, 0);
}
fn assert_preflight_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_preflight_source_summary_matches_report(summary, report);
assert_preflight_restore_identity_summary_matches_report(summary, report);
assert_preflight_restore_readiness_summary_matches_report(summary, report);
assert_preflight_restore_snapshot_summary_matches_report(summary, report);
assert_preflight_restore_verification_summary_matches_report(summary, report);
assert_preflight_restore_operation_summary_matches_report(summary, report);
assert_preflight_restore_ordering_summary_matches_report(summary, report);
assert_preflight_path_summary_matches_report(summary, report);
}
fn assert_preflight_source_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_eq!(summary["status"], report.status);
assert_eq!(summary["backup_id"], report.backup_id);
assert_eq!(summary["source_environment"], report.source_environment);
assert_eq!(summary["source_root_canister"], report.source_root_canister);
assert_eq!(summary["topology_hash"], report.topology_hash);
assert_eq!(summary["journal_complete"], report.journal_complete);
assert_eq!(
summary["journal_operation_metrics"],
json!(report.journal_operation_metrics)
);
assert_eq!(summary["inspection_status"], report.inspection_status);
assert_eq!(summary["provenance_status"], report.provenance_status);
assert_eq!(summary["backup_id_status"], report.backup_id_status);
assert_eq!(
summary["topology_receipts_status"],
report.topology_receipts_status
);
assert_eq!(
summary["topology_mismatch_count"],
report.topology_mismatch_count
);
assert_eq!(summary["integrity_verified"], report.integrity_verified);
assert_eq!(
summary["manifest_design_v1_ready"],
report.manifest_design_v1_ready
);
assert_eq!(summary["manifest_members"], report.manifest_members);
assert_eq!(summary["backup_unit_count"], report.backup_unit_count);
assert_eq!(summary["restore_plan_members"], report.restore_plan_members);
assert_eq!(
summary["restore_mapping_supplied"],
report.restore_mapping_supplied
);
assert_eq!(
summary["restore_all_sources_mapped"],
report.restore_all_sources_mapped
);
}
fn assert_preflight_restore_identity_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_eq!(
summary["restore_fixed_members"],
report.restore_fixed_members
);
assert_eq!(
summary["restore_relocatable_members"],
report.restore_relocatable_members
);
assert_eq!(
summary["restore_in_place_members"],
report.restore_in_place_members
);
assert_eq!(
summary["restore_mapped_members"],
report.restore_mapped_members
);
assert_eq!(
summary["restore_remapped_members"],
report.restore_remapped_members
);
}
fn assert_preflight_restore_readiness_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_eq!(summary["restore_ready"], report.restore_ready);
assert_eq!(
summary["restore_readiness_reasons"],
json!(report.restore_readiness_reasons)
);
}
fn assert_preflight_restore_snapshot_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_eq!(
summary["restore_all_members_have_module_hash"],
report.restore_all_members_have_module_hash
);
assert_eq!(
summary["restore_all_members_have_wasm_hash"],
report.restore_all_members_have_wasm_hash
);
assert_eq!(
summary["restore_all_members_have_code_version"],
report.restore_all_members_have_code_version
);
assert_eq!(
summary["restore_all_members_have_checksum"],
report.restore_all_members_have_checksum
);
assert_eq!(
summary["restore_members_with_module_hash"],
report.restore_members_with_module_hash
);
assert_eq!(
summary["restore_members_with_wasm_hash"],
report.restore_members_with_wasm_hash
);
assert_eq!(
summary["restore_members_with_code_version"],
report.restore_members_with_code_version
);
assert_eq!(
summary["restore_members_with_checksum"],
report.restore_members_with_checksum
);
}
fn assert_preflight_restore_verification_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_eq!(
summary["restore_verification_required"],
report.restore_verification_required
);
assert_eq!(
summary["restore_all_members_have_checks"],
report.restore_all_members_have_checks
);
assert_eq!(summary["restore_fleet_checks"], report.restore_fleet_checks);
assert_eq!(
summary["restore_member_check_groups"],
report.restore_member_check_groups
);
assert_eq!(
summary["restore_member_checks"],
report.restore_member_checks
);
assert_eq!(
summary["restore_members_with_checks"],
report.restore_members_with_checks
);
assert_eq!(summary["restore_total_checks"], report.restore_total_checks);
}
fn assert_preflight_restore_operation_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_eq!(
summary["restore_planned_snapshot_uploads"],
report.restore_planned_snapshot_uploads
);
assert_eq!(
summary["restore_planned_snapshot_loads"],
report.restore_planned_snapshot_loads
);
assert_eq!(
summary["restore_planned_code_reinstalls"],
report.restore_planned_code_reinstalls
);
assert_eq!(
summary["restore_planned_verification_checks"],
report.restore_planned_verification_checks
);
assert_eq!(
summary["restore_planned_operations"],
report.restore_planned_operations
);
assert_eq!(
summary["restore_planned_phases"],
report.restore_planned_phases
);
}
fn assert_preflight_restore_ordering_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_eq!(summary["restore_phase_count"], report.restore_phase_count);
assert_eq!(
summary["restore_dependency_free_members"],
report.restore_dependency_free_members
);
assert_eq!(
summary["restore_in_group_parent_edges"],
report.restore_in_group_parent_edges
);
assert_eq!(
summary["restore_cross_group_parent_edges"],
report.restore_cross_group_parent_edges
);
}
fn assert_preflight_path_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_eq!(
summary["manifest_validation_path"],
report.manifest_validation_path
);
assert_eq!(summary["backup_status_path"], report.backup_status_path);
assert_eq!(
summary["backup_inspection_path"],
report.backup_inspection_path
);
assert_eq!(
summary["backup_provenance_path"],
report.backup_provenance_path
);
assert_eq!(
summary["backup_integrity_path"],
report.backup_integrity_path
);
assert_eq!(summary["restore_plan_path"], report.restore_plan_path);
assert_eq!(summary["restore_status_path"], report.restore_status_path);
assert_eq!(
summary["preflight_summary_path"],
report.preflight_summary_path
);
}
#[test]
fn backup_preflight_rejects_incomplete_journal() {
let root = temp_dir("canic-cli-backup-preflight-incomplete");
let out_dir = root.join("reports");
let backup_dir = root.join("backup");
let layout = BackupLayout::new(backup_dir.clone());
layout
.write_manifest(&valid_manifest())
.expect("write manifest");
layout
.write_journal(&created_journal())
.expect("write journal");
let options = BackupPreflightOptions {
dir: backup_dir,
out_dir,
mapping: None,
require_design_v1: false,
require_restore_ready: false,
};
let err = backup_preflight(&options).expect_err("incomplete journal should fail");
fs::remove_dir_all(root).expect("remove temp root");
assert!(matches!(
err,
BackupCommandError::IncompleteJournal {
pending_artifacts: 1,
total_artifacts: 1,
..
}
));
}
#[test]
fn parses_backup_verify_options() {
let options = BackupVerifyOptions::parse([
OsString::from("--dir"),
OsString::from("backups/run"),
OsString::from("--out"),
OsString::from("report.json"),
])
.expect("parse options");
assert_eq!(options.dir, PathBuf::from("backups/run"));
assert_eq!(options.out, Some(PathBuf::from("report.json")));
}
#[test]
fn parses_backup_inspect_options() {
let options = BackupInspectOptions::parse([
OsString::from("--dir"),
OsString::from("backups/run"),
OsString::from("--out"),
OsString::from("inspect.json"),
OsString::from("--require-ready"),
])
.expect("parse options");
assert_eq!(options.dir, PathBuf::from("backups/run"));
assert_eq!(options.out, Some(PathBuf::from("inspect.json")));
assert!(options.require_ready);
}
#[test]
fn parses_backup_provenance_options() {
let options = BackupProvenanceOptions::parse([
OsString::from("--dir"),
OsString::from("backups/run"),
OsString::from("--out"),
OsString::from("provenance.json"),
OsString::from("--require-consistent"),
])
.expect("parse options");
assert_eq!(options.dir, PathBuf::from("backups/run"));
assert_eq!(options.out, Some(PathBuf::from("provenance.json")));
assert!(options.require_consistent);
}
#[test]
fn parses_backup_status_options() {
let options = BackupStatusOptions::parse([
OsString::from("--dir"),
OsString::from("backups/run"),
OsString::from("--out"),
OsString::from("status.json"),
OsString::from("--require-complete"),
])
.expect("parse options");
assert_eq!(options.dir, PathBuf::from("backups/run"));
assert_eq!(options.out, Some(PathBuf::from("status.json")));
assert!(options.require_complete);
}
#[test]
fn backup_status_reads_journal_resume_report() {
let root = temp_dir("canic-cli-backup-status");
let layout = BackupLayout::new(root.clone());
layout
.write_journal(&journal_with_checksum(HASH.to_string()))
.expect("write journal");
let options = BackupStatusOptions {
dir: root.clone(),
out: None,
require_complete: false,
};
let report = backup_status(&options).expect("read backup status");
fs::remove_dir_all(root).expect("remove temp root");
assert_eq!(report.backup_id, "backup-test");
assert_eq!(report.total_artifacts, 1);
assert!(report.is_complete);
assert_eq!(report.pending_artifacts, 0);
assert_eq!(report.counts.skip, 1);
}
#[test]
fn inspect_backup_reads_layout_metadata() {
let root = temp_dir("canic-cli-backup-inspect");
let layout = BackupLayout::new(root.clone());
layout
.write_manifest(&valid_manifest())
.expect("write manifest");
layout
.write_journal(&journal_with_checksum(HASH.to_string()))
.expect("write journal");
let options = BackupInspectOptions {
dir: root.clone(),
out: None,
require_ready: false,
};
let report = inspect_backup(&options).expect("inspect backup");
fs::remove_dir_all(root).expect("remove temp root");
assert_eq!(report.backup_id, "backup-test");
assert!(report.backup_id_matches);
assert!(report.journal_complete);
assert!(report.ready_for_verify);
assert!(report.topology_receipt_mismatches.is_empty());
assert_eq!(report.matched_artifacts, 1);
}
#[test]
fn backup_provenance_reads_layout_metadata() {
let root = temp_dir("canic-cli-backup-provenance");
let layout = BackupLayout::new(root.clone());
layout
.write_manifest(&valid_manifest())
.expect("write manifest");
layout
.write_journal(&journal_with_checksum(HASH.to_string()))
.expect("write journal");
let options = BackupProvenanceOptions {
dir: root.clone(),
out: None,
require_consistent: false,
};
let report = backup_provenance(&options).expect("read provenance");
fs::remove_dir_all(root).expect("remove temp root");
assert_eq!(report.backup_id, "backup-test");
assert!(report.backup_id_matches);
assert_eq!(report.source_environment, "local");
assert_eq!(report.discovery_topology_hash, HASH);
assert!(report.topology_receipts_match);
assert!(report.topology_receipt_mismatches.is_empty());
assert_eq!(report.backup_unit_count, 1);
assert_eq!(report.member_count, 1);
assert_eq!(report.backup_units[0].kind, "subtree-rooted");
assert_eq!(report.members[0].canister_id, ROOT);
assert_eq!(report.members[0].snapshot_id, "root-snapshot");
assert_eq!(report.members[0].journal_state, Some("Durable".to_string()));
}
#[test]
fn require_consistent_accepts_matching_provenance() {
let options = BackupProvenanceOptions {
dir: PathBuf::from("unused"),
out: None,
require_consistent: true,
};
let report = ready_provenance_report();
enforce_provenance_requirements(&options, &report)
.expect("matching provenance should pass");
}
#[test]
fn require_consistent_rejects_provenance_drift() {
let options = BackupProvenanceOptions {
dir: PathBuf::from("unused"),
out: None,
require_consistent: true,
};
let mut report = ready_provenance_report();
report.backup_id_matches = false;
report.journal_backup_id = "other-backup".to_string();
report.topology_receipts_match = false;
report.topology_receipt_mismatches.push(
canic_backup::persistence::TopologyReceiptMismatch {
field: "pre_snapshot_topology_hash".to_string(),
manifest: HASH.to_string(),
journal: None,
},
);
let err = enforce_provenance_requirements(&options, &report)
.expect_err("provenance drift should fail");
assert!(matches!(
err,
BackupCommandError::ProvenanceNotConsistent {
backup_id_matches: false,
topology_receipts_match: false,
topology_mismatches: 1,
..
}
));
}
#[test]
fn require_ready_accepts_ready_inspection() {
let options = BackupInspectOptions {
dir: PathBuf::from("unused"),
out: None,
require_ready: true,
};
let report = ready_inspection_report();
enforce_inspection_requirements(&options, &report).expect("ready inspection should pass");
}
#[test]
fn require_ready_rejects_unready_inspection() {
let options = BackupInspectOptions {
dir: PathBuf::from("unused"),
out: None,
require_ready: true,
};
let mut report = ready_inspection_report();
report.ready_for_verify = false;
report
.path_mismatches
.push(canic_backup::persistence::ArtifactPathMismatch {
canister_id: ROOT.to_string(),
snapshot_id: "root-snapshot".to_string(),
manifest: "artifacts/root".to_string(),
journal: "artifacts/other-root".to_string(),
});
let err = enforce_inspection_requirements(&options, &report)
.expect_err("unready inspection should fail");
assert!(matches!(
err,
BackupCommandError::InspectionNotReady {
path_mismatches: 1,
..
}
));
}
#[test]
fn require_ready_rejects_topology_receipt_drift() {
let options = BackupInspectOptions {
dir: PathBuf::from("unused"),
out: None,
require_ready: true,
};
let mut report = ready_inspection_report();
report.ready_for_verify = false;
report.topology_receipt_mismatches.push(
canic_backup::persistence::TopologyReceiptMismatch {
field: "discovery_topology_hash".to_string(),
manifest: HASH.to_string(),
journal: None,
},
);
let err = enforce_inspection_requirements(&options, &report)
.expect_err("topology receipt drift should fail");
assert!(matches!(
err,
BackupCommandError::InspectionNotReady {
topology_receipts_match: false,
topology_mismatches: 1,
..
}
));
}
#[test]
fn require_complete_accepts_complete_status() {
let options = BackupStatusOptions {
dir: PathBuf::from("unused"),
out: None,
require_complete: true,
};
let report = journal_with_checksum(HASH.to_string()).resume_report();
enforce_status_requirements(&options, &report).expect("complete status should pass");
}
#[test]
fn require_complete_rejects_incomplete_status() {
let options = BackupStatusOptions {
dir: PathBuf::from("unused"),
out: None,
require_complete: true,
};
let report = created_journal().resume_report();
let err = enforce_status_requirements(&options, &report)
.expect_err("incomplete status should fail");
assert!(matches!(
err,
BackupCommandError::IncompleteJournal {
pending_artifacts: 1,
total_artifacts: 1,
..
}
));
}
#[test]
fn verify_backup_reads_layout_and_artifacts() {
let root = temp_dir("canic-cli-backup-verify");
let layout = BackupLayout::new(root.clone());
let checksum = write_artifact(&root, b"root artifact");
layout
.write_manifest(&valid_manifest())
.expect("write manifest");
layout
.write_journal(&journal_with_checksum(checksum.hash.clone()))
.expect("write journal");
let options = BackupVerifyOptions {
dir: root.clone(),
out: None,
};
let report = verify_backup(&options).expect("verify backup");
fs::remove_dir_all(root).expect("remove temp root");
assert_eq!(report.backup_id, "backup-test");
assert!(report.verified);
assert_eq!(report.durable_artifacts, 1);
assert_eq!(report.artifacts[0].checksum, checksum.hash);
}
fn valid_manifest() -> FleetBackupManifest {
FleetBackupManifest {
manifest_version: 1,
backup_id: "backup-test".to_string(),
created_at: "2026-05-03T00:00:00Z".to_string(),
tool: ToolMetadata {
name: "canic".to_string(),
version: "0.30.3".to_string(),
},
source: SourceMetadata {
environment: "local".to_string(),
root_canister: ROOT.to_string(),
},
consistency: ConsistencySection {
mode: ConsistencyMode::CrashConsistent,
backup_units: vec![BackupUnit {
unit_id: "fleet".to_string(),
kind: BackupUnitKind::SubtreeRooted,
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![fleet_member()],
},
verification: VerificationPlan::default(),
}
}
fn fleet_member() -> FleetMember {
FleetMember {
role: "root".to_string(),
canister_id: ROOT.to_string(),
parent_canister_id: None,
subnet_canister_id: Some(ROOT.to_string()),
controller_hint: None,
identity_mode: IdentityMode::Fixed,
restore_group: 1,
verification_class: "basic".to_string(),
verification_checks: vec![VerificationCheck {
kind: "status".to_string(),
method: None,
roles: vec!["root".to_string()],
}],
source_snapshot: SourceSnapshot {
snapshot_id: "root-snapshot".to_string(),
module_hash: None,
wasm_hash: None,
code_version: Some("v0.30.3".to_string()),
artifact_path: "artifacts/root".to_string(),
checksum_algorithm: "sha256".to_string(),
checksum: None,
},
}
}
fn restore_ready_manifest(checksum: &str) -> FleetBackupManifest {
let mut manifest = valid_manifest();
let snapshot = &mut manifest.fleet.members[0].source_snapshot;
snapshot.module_hash = Some(HASH.to_string());
snapshot.wasm_hash = Some(HASH.to_string());
snapshot.checksum = Some(checksum.to_string());
manifest
}
fn journal_with_checksum(checksum: String) -> DownloadJournal {
DownloadJournal {
journal_version: 1,
backup_id: "backup-test".to_string(),
discovery_topology_hash: Some(HASH.to_string()),
pre_snapshot_topology_hash: Some(HASH.to_string()),
operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
artifacts: vec![ArtifactJournalEntry {
canister_id: ROOT.to_string(),
snapshot_id: "root-snapshot".to_string(),
state: ArtifactState::Durable,
temp_path: None,
artifact_path: "artifacts/root".to_string(),
checksum_algorithm: "sha256".to_string(),
checksum: Some(checksum),
updated_at: "2026-05-03T00:00:00Z".to_string(),
}],
}
}
fn created_journal() -> DownloadJournal {
DownloadJournal {
journal_version: 1,
backup_id: "backup-test".to_string(),
discovery_topology_hash: Some(HASH.to_string()),
pre_snapshot_topology_hash: Some(HASH.to_string()),
operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
artifacts: vec![ArtifactJournalEntry {
canister_id: ROOT.to_string(),
snapshot_id: "root-snapshot".to_string(),
state: ArtifactState::Created,
temp_path: None,
artifact_path: "artifacts/root".to_string(),
checksum_algorithm: "sha256".to_string(),
checksum: None,
updated_at: "2026-05-03T00:00:00Z".to_string(),
}],
}
}
fn ready_inspection_report() -> BackupInspectionReport {
BackupInspectionReport {
backup_id: "backup-test".to_string(),
manifest_backup_id: "backup-test".to_string(),
journal_backup_id: "backup-test".to_string(),
backup_id_matches: true,
journal_complete: true,
ready_for_verify: true,
manifest_members: 1,
journal_artifacts: 1,
matched_artifacts: 1,
topology_receipt_mismatches: Vec::new(),
missing_journal_artifacts: Vec::new(),
unexpected_journal_artifacts: Vec::new(),
path_mismatches: Vec::new(),
checksum_mismatches: Vec::new(),
}
}
fn ready_provenance_report() -> BackupProvenanceReport {
BackupProvenanceReport {
backup_id: "backup-test".to_string(),
manifest_backup_id: "backup-test".to_string(),
journal_backup_id: "backup-test".to_string(),
backup_id_matches: true,
manifest_version: 1,
journal_version: 1,
created_at: "2026-05-03T00:00:00Z".to_string(),
tool_name: "canic".to_string(),
tool_version: "0.30.12".to_string(),
source_environment: "local".to_string(),
source_root_canister: ROOT.to_string(),
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(),
accepted_topology_hash: HASH.to_string(),
journal_discovery_topology_hash: Some(HASH.to_string()),
journal_pre_snapshot_topology_hash: Some(HASH.to_string()),
topology_receipts_match: true,
topology_receipt_mismatches: Vec::new(),
backup_unit_count: 1,
member_count: 1,
consistency_mode: "crash-consistent".to_string(),
backup_units: Vec::new(),
members: Vec::new(),
}
}
fn write_artifact(root: &Path, bytes: &[u8]) -> ArtifactChecksum {
let path = root.join("artifacts/root");
fs::create_dir_all(path.parent().expect("artifact has parent")).expect("create artifacts");
fs::write(&path, bytes).expect("write artifact");
ArtifactChecksum::from_bytes(bytes)
}
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()))
}
}