Skip to main content

canic_cli/backup/
mod.rs

1use canic_backup::{
2    journal::{DownloadOperationMetrics, JournalResumeReport},
3    manifest::{BackupUnitKind, ConsistencyMode, FleetBackupManifest},
4    persistence::{
5        BackupInspectionReport, BackupIntegrityReport, BackupLayout, BackupProvenanceReport,
6        PersistenceError,
7    },
8    restore::{RestoreMapping, RestorePlan, RestorePlanError, RestorePlanner},
9};
10use serde_json::json;
11use std::{
12    ffi::OsString,
13    fs,
14    io::{self, Write},
15    path::{Path, PathBuf},
16};
17use thiserror::Error as ThisError;
18
19///
20/// BackupCommandError
21///
22
23#[derive(Debug, ThisError)]
24pub enum BackupCommandError {
25    #[error("{0}")]
26    Usage(&'static str),
27
28    #[error("missing required option {0}")]
29    MissingOption(&'static str),
30
31    #[error("unknown option {0}")]
32    UnknownOption(String),
33
34    #[error("option {0} requires a value")]
35    MissingValue(&'static str),
36
37    #[error(
38        "backup journal {backup_id} is incomplete: {pending_artifacts}/{total_artifacts} artifacts still require resume work"
39    )]
40    IncompleteJournal {
41        backup_id: String,
42        total_artifacts: usize,
43        pending_artifacts: usize,
44    },
45
46    #[error(
47        "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}"
48    )]
49    InspectionNotReady {
50        backup_id: String,
51        backup_id_matches: bool,
52        topology_receipts_match: bool,
53        journal_complete: bool,
54        topology_mismatches: usize,
55        missing_artifacts: usize,
56        unexpected_artifacts: usize,
57        path_mismatches: usize,
58        checksum_mismatches: usize,
59    },
60
61    #[error(
62        "backup provenance {backup_id} is not consistent: backup_id_matches={backup_id_matches}, topology_receipts_match={topology_receipts_match}, topology_mismatches={topology_mismatches}"
63    )]
64    ProvenanceNotConsistent {
65        backup_id: String,
66        backup_id_matches: bool,
67        topology_receipts_match: bool,
68        topology_mismatches: usize,
69    },
70
71    #[error(transparent)]
72    Io(#[from] std::io::Error),
73
74    #[error(transparent)]
75    Json(#[from] serde_json::Error),
76
77    #[error(transparent)]
78    Persistence(#[from] PersistenceError),
79
80    #[error(transparent)]
81    RestorePlan(#[from] RestorePlanError),
82}
83
84///
85/// BackupPreflightOptions
86///
87
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct BackupPreflightOptions {
90    pub dir: PathBuf,
91    pub out_dir: PathBuf,
92    pub mapping: Option<PathBuf>,
93}
94
95impl BackupPreflightOptions {
96    /// Parse backup preflight options from CLI arguments.
97    pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
98    where
99        I: IntoIterator<Item = OsString>,
100    {
101        let mut dir = None;
102        let mut out_dir = None;
103        let mut mapping = None;
104
105        let mut args = args.into_iter();
106        while let Some(arg) = args.next() {
107            let arg = arg
108                .into_string()
109                .map_err(|_| BackupCommandError::Usage(usage()))?;
110            match arg.as_str() {
111                "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
112                "--out-dir" => out_dir = Some(PathBuf::from(next_value(&mut args, "--out-dir")?)),
113                "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
114                "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
115                _ => return Err(BackupCommandError::UnknownOption(arg)),
116            }
117        }
118
119        Ok(Self {
120            dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
121            out_dir: out_dir.ok_or(BackupCommandError::MissingOption("--out-dir"))?,
122            mapping,
123        })
124    }
125}
126
127///
128/// BackupPreflightReport
129///
130
131#[derive(Clone, Debug, Eq, PartialEq)]
132#[expect(
133    clippy::struct_excessive_bools,
134    reason = "preflight reports intentionally mirror machine-readable JSON status flags"
135)]
136pub struct BackupPreflightReport {
137    pub status: String,
138    pub backup_id: String,
139    pub backup_dir: String,
140    pub source_environment: String,
141    pub source_root_canister: String,
142    pub topology_hash: String,
143    pub mapping_path: Option<String>,
144    pub journal_complete: bool,
145    pub journal_operation_metrics: DownloadOperationMetrics,
146    pub inspection_status: String,
147    pub provenance_status: String,
148    pub backup_id_status: String,
149    pub topology_receipts_status: String,
150    pub topology_mismatch_count: usize,
151    pub integrity_verified: bool,
152    pub manifest_members: usize,
153    pub backup_unit_count: usize,
154    pub restore_plan_members: usize,
155    pub restore_mapping_supplied: bool,
156    pub restore_all_sources_mapped: bool,
157    pub restore_fixed_members: usize,
158    pub restore_relocatable_members: usize,
159    pub restore_in_place_members: usize,
160    pub restore_mapped_members: usize,
161    pub restore_remapped_members: usize,
162    pub restore_ready: bool,
163    pub restore_readiness_reasons: Vec<String>,
164    pub restore_all_members_have_module_hash: bool,
165    pub restore_all_members_have_wasm_hash: bool,
166    pub restore_all_members_have_code_version: bool,
167    pub restore_all_members_have_checksum: bool,
168    pub restore_members_with_module_hash: usize,
169    pub restore_members_with_wasm_hash: usize,
170    pub restore_members_with_code_version: usize,
171    pub restore_members_with_checksum: usize,
172    pub restore_verification_required: bool,
173    pub restore_all_members_have_checks: bool,
174    pub restore_fleet_checks: usize,
175    pub restore_member_check_groups: usize,
176    pub restore_member_checks: usize,
177    pub restore_members_with_checks: usize,
178    pub restore_total_checks: usize,
179    pub restore_planned_snapshot_loads: usize,
180    pub restore_planned_code_reinstalls: usize,
181    pub restore_planned_verification_checks: usize,
182    pub restore_planned_phases: usize,
183    pub restore_phase_count: usize,
184    pub restore_dependency_free_members: usize,
185    pub restore_in_group_parent_edges: usize,
186    pub restore_cross_group_parent_edges: usize,
187    pub manifest_validation_path: String,
188    pub backup_status_path: String,
189    pub backup_inspection_path: String,
190    pub backup_provenance_path: String,
191    pub backup_integrity_path: String,
192    pub restore_plan_path: String,
193    pub preflight_summary_path: String,
194}
195
196///
197/// PreflightArtifactPaths
198///
199
200struct PreflightArtifactPaths {
201    manifest_validation: PathBuf,
202    backup_status: PathBuf,
203    backup_inspection: PathBuf,
204    backup_provenance: PathBuf,
205    backup_integrity: PathBuf,
206    restore_plan: PathBuf,
207    preflight_summary: PathBuf,
208}
209
210///
211/// PreflightReportInput
212///
213
214struct PreflightReportInput<'a> {
215    options: &'a BackupPreflightOptions,
216    manifest: &'a FleetBackupManifest,
217    status: &'a JournalResumeReport,
218    inspection: &'a BackupInspectionReport,
219    provenance: &'a BackupProvenanceReport,
220    integrity: &'a BackupIntegrityReport,
221    restore_plan: &'a RestorePlan,
222    paths: &'a PreflightArtifactPaths,
223}
224
225///
226/// BackupInspectOptions
227///
228
229#[derive(Clone, Debug, Eq, PartialEq)]
230pub struct BackupInspectOptions {
231    pub dir: PathBuf,
232    pub out: Option<PathBuf>,
233    pub require_ready: bool,
234}
235
236impl BackupInspectOptions {
237    /// Parse backup inspection options from CLI arguments.
238    pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
239    where
240        I: IntoIterator<Item = OsString>,
241    {
242        let mut dir = None;
243        let mut out = None;
244        let mut require_ready = false;
245
246        let mut args = args.into_iter();
247        while let Some(arg) = args.next() {
248            let arg = arg
249                .into_string()
250                .map_err(|_| BackupCommandError::Usage(usage()))?;
251            match arg.as_str() {
252                "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
253                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
254                "--require-ready" => require_ready = true,
255                "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
256                _ => return Err(BackupCommandError::UnknownOption(arg)),
257            }
258        }
259
260        Ok(Self {
261            dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
262            out,
263            require_ready,
264        })
265    }
266}
267
268///
269/// BackupProvenanceOptions
270///
271
272#[derive(Clone, Debug, Eq, PartialEq)]
273pub struct BackupProvenanceOptions {
274    pub dir: PathBuf,
275    pub out: Option<PathBuf>,
276    pub require_consistent: bool,
277}
278
279impl BackupProvenanceOptions {
280    /// Parse backup provenance options from CLI arguments.
281    pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
282    where
283        I: IntoIterator<Item = OsString>,
284    {
285        let mut dir = None;
286        let mut out = None;
287        let mut require_consistent = false;
288
289        let mut args = args.into_iter();
290        while let Some(arg) = args.next() {
291            let arg = arg
292                .into_string()
293                .map_err(|_| BackupCommandError::Usage(usage()))?;
294            match arg.as_str() {
295                "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
296                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
297                "--require-consistent" => require_consistent = true,
298                "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
299                _ => return Err(BackupCommandError::UnknownOption(arg)),
300            }
301        }
302
303        Ok(Self {
304            dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
305            out,
306            require_consistent,
307        })
308    }
309}
310
311///
312/// BackupVerifyOptions
313///
314
315#[derive(Clone, Debug, Eq, PartialEq)]
316pub struct BackupVerifyOptions {
317    pub dir: PathBuf,
318    pub out: Option<PathBuf>,
319}
320
321impl BackupVerifyOptions {
322    /// Parse backup verification options from CLI arguments.
323    pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
324    where
325        I: IntoIterator<Item = OsString>,
326    {
327        let mut dir = None;
328        let mut out = None;
329
330        let mut args = args.into_iter();
331        while let Some(arg) = args.next() {
332            let arg = arg
333                .into_string()
334                .map_err(|_| BackupCommandError::Usage(usage()))?;
335            match arg.as_str() {
336                "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
337                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
338                "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
339                _ => return Err(BackupCommandError::UnknownOption(arg)),
340            }
341        }
342
343        Ok(Self {
344            dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
345            out,
346        })
347    }
348}
349
350///
351/// BackupStatusOptions
352///
353
354#[derive(Clone, Debug, Eq, PartialEq)]
355pub struct BackupStatusOptions {
356    pub dir: PathBuf,
357    pub out: Option<PathBuf>,
358    pub require_complete: bool,
359}
360
361impl BackupStatusOptions {
362    /// Parse backup status options from CLI arguments.
363    pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
364    where
365        I: IntoIterator<Item = OsString>,
366    {
367        let mut dir = None;
368        let mut out = None;
369        let mut require_complete = false;
370
371        let mut args = args.into_iter();
372        while let Some(arg) = args.next() {
373            let arg = arg
374                .into_string()
375                .map_err(|_| BackupCommandError::Usage(usage()))?;
376            match arg.as_str() {
377                "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
378                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
379                "--require-complete" => require_complete = true,
380                "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
381                _ => return Err(BackupCommandError::UnknownOption(arg)),
382            }
383        }
384
385        Ok(Self {
386            dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
387            out,
388            require_complete,
389        })
390    }
391}
392
393/// Run a backup subcommand.
394pub fn run<I>(args: I) -> Result<(), BackupCommandError>
395where
396    I: IntoIterator<Item = OsString>,
397{
398    let mut args = args.into_iter();
399    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
400        return Err(BackupCommandError::Usage(usage()));
401    };
402
403    match command.as_str() {
404        "preflight" => {
405            let options = BackupPreflightOptions::parse(args)?;
406            backup_preflight(&options)?;
407            Ok(())
408        }
409        "inspect" => {
410            let options = BackupInspectOptions::parse(args)?;
411            let report = inspect_backup(&options)?;
412            write_inspect_report(&options, &report)?;
413            enforce_inspection_requirements(&options, &report)?;
414            Ok(())
415        }
416        "provenance" => {
417            let options = BackupProvenanceOptions::parse(args)?;
418            let report = backup_provenance(&options)?;
419            write_provenance_report(&options, &report)?;
420            enforce_provenance_requirements(&options, &report)?;
421            Ok(())
422        }
423        "status" => {
424            let options = BackupStatusOptions::parse(args)?;
425            let report = backup_status(&options)?;
426            write_status_report(&options, &report)?;
427            enforce_status_requirements(&options, &report)?;
428            Ok(())
429        }
430        "verify" => {
431            let options = BackupVerifyOptions::parse(args)?;
432            let report = verify_backup(&options)?;
433            write_report(&options, &report)?;
434            Ok(())
435        }
436        "help" | "--help" | "-h" => Err(BackupCommandError::Usage(usage())),
437        _ => Err(BackupCommandError::UnknownOption(command)),
438    }
439}
440
441/// Run all no-mutation backup checks and write standard preflight artifacts.
442pub fn backup_preflight(
443    options: &BackupPreflightOptions,
444) -> Result<BackupPreflightReport, BackupCommandError> {
445    fs::create_dir_all(&options.out_dir)?;
446
447    let layout = BackupLayout::new(options.dir.clone());
448    let manifest = layout.read_manifest()?;
449    let status = layout.read_journal()?.resume_report();
450    ensure_complete_status(&status)?;
451    let inspection = layout.inspect()?;
452    let provenance = layout.provenance()?;
453    let integrity = layout.verify_integrity()?;
454    let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
455    let restore_plan = RestorePlanner::plan(&manifest, mapping.as_ref())?;
456    let paths = preflight_artifact_paths(&options.out_dir);
457
458    write_preflight_artifacts(
459        &paths,
460        &manifest,
461        &status,
462        &inspection,
463        &provenance,
464        &integrity,
465        &restore_plan,
466    )?;
467    let report = build_preflight_report(PreflightReportInput {
468        options,
469        manifest: &manifest,
470        status: &status,
471        inspection: &inspection,
472        provenance: &provenance,
473        integrity: &integrity,
474        restore_plan: &restore_plan,
475        paths: &paths,
476    });
477    write_json_value_file(&paths.preflight_summary, &preflight_summary_value(&report))?;
478    Ok(report)
479}
480
481// Build the standard preflight artifact path set under one output directory.
482fn preflight_artifact_paths(out_dir: &Path) -> PreflightArtifactPaths {
483    PreflightArtifactPaths {
484        manifest_validation: out_dir.join("manifest-validation.json"),
485        backup_status: out_dir.join("backup-status.json"),
486        backup_inspection: out_dir.join("backup-inspection.json"),
487        backup_provenance: out_dir.join("backup-provenance.json"),
488        backup_integrity: out_dir.join("backup-integrity.json"),
489        restore_plan: out_dir.join("restore-plan.json"),
490        preflight_summary: out_dir.join("preflight-summary.json"),
491    }
492}
493
494// Write the standard preflight artifacts before emitting the compact summary.
495fn write_preflight_artifacts(
496    paths: &PreflightArtifactPaths,
497    manifest: &FleetBackupManifest,
498    status: &JournalResumeReport,
499    inspection: &BackupInspectionReport,
500    provenance: &BackupProvenanceReport,
501    integrity: &BackupIntegrityReport,
502    restore_plan: &RestorePlan,
503) -> Result<(), BackupCommandError> {
504    write_json_value_file(
505        &paths.manifest_validation,
506        &manifest_validation_summary(manifest),
507    )?;
508    fs::write(&paths.backup_status, serde_json::to_vec_pretty(&status)?)?;
509    fs::write(
510        &paths.backup_inspection,
511        serde_json::to_vec_pretty(&inspection)?,
512    )?;
513    fs::write(
514        &paths.backup_provenance,
515        serde_json::to_vec_pretty(&provenance)?,
516    )?;
517    fs::write(
518        &paths.backup_integrity,
519        serde_json::to_vec_pretty(&integrity)?,
520    )?;
521    fs::write(
522        &paths.restore_plan,
523        serde_json::to_vec_pretty(&restore_plan)?,
524    )?;
525    Ok(())
526}
527
528// Build the in-memory preflight report mirrored by preflight-summary.json.
529fn build_preflight_report(input: PreflightReportInput<'_>) -> BackupPreflightReport {
530    let identity = &input.restore_plan.identity_summary;
531    let snapshot = &input.restore_plan.snapshot_summary;
532    let verification = &input.restore_plan.verification_summary;
533    let operation = &input.restore_plan.operation_summary;
534    let ordering = &input.restore_plan.ordering_summary;
535
536    BackupPreflightReport {
537        status: "ready".to_string(),
538        backup_id: input.manifest.backup_id.clone(),
539        backup_dir: input.options.dir.display().to_string(),
540        source_environment: input.manifest.source.environment.clone(),
541        source_root_canister: input.manifest.source.root_canister.clone(),
542        topology_hash: input.manifest.fleet.topology_hash.clone(),
543        mapping_path: input
544            .options
545            .mapping
546            .as_ref()
547            .map(|path| path.display().to_string()),
548        journal_complete: input.status.is_complete,
549        journal_operation_metrics: input.status.operation_metrics.clone(),
550        inspection_status: readiness_status(input.inspection.ready_for_verify).to_string(),
551        provenance_status: consistency_status(
552            input.provenance.backup_id_matches && input.provenance.topology_receipts_match,
553        )
554        .to_string(),
555        backup_id_status: match_status(input.provenance.backup_id_matches).to_string(),
556        topology_receipts_status: match_status(input.provenance.topology_receipts_match)
557            .to_string(),
558        topology_mismatch_count: input.provenance.topology_receipt_mismatches.len(),
559        integrity_verified: input.integrity.verified,
560        manifest_members: input.manifest.fleet.members.len(),
561        backup_unit_count: input.provenance.backup_unit_count,
562        restore_plan_members: input.restore_plan.member_count,
563        restore_mapping_supplied: identity.mapping_supplied,
564        restore_all_sources_mapped: identity.all_sources_mapped,
565        restore_fixed_members: identity.fixed_members,
566        restore_relocatable_members: identity.relocatable_members,
567        restore_in_place_members: identity.in_place_members,
568        restore_mapped_members: identity.mapped_members,
569        restore_remapped_members: identity.remapped_members,
570        restore_ready: input.restore_plan.readiness_summary.ready,
571        restore_readiness_reasons: input.restore_plan.readiness_summary.reasons.clone(),
572        restore_all_members_have_module_hash: snapshot.all_members_have_module_hash,
573        restore_all_members_have_wasm_hash: snapshot.all_members_have_wasm_hash,
574        restore_all_members_have_code_version: snapshot.all_members_have_code_version,
575        restore_all_members_have_checksum: snapshot.all_members_have_checksum,
576        restore_members_with_module_hash: snapshot.members_with_module_hash,
577        restore_members_with_wasm_hash: snapshot.members_with_wasm_hash,
578        restore_members_with_code_version: snapshot.members_with_code_version,
579        restore_members_with_checksum: snapshot.members_with_checksum,
580        restore_verification_required: verification.verification_required,
581        restore_all_members_have_checks: verification.all_members_have_checks,
582        restore_fleet_checks: verification.fleet_checks,
583        restore_member_check_groups: verification.member_check_groups,
584        restore_member_checks: verification.member_checks,
585        restore_members_with_checks: verification.members_with_checks,
586        restore_total_checks: verification.total_checks,
587        restore_planned_snapshot_loads: operation.planned_snapshot_loads,
588        restore_planned_code_reinstalls: operation.planned_code_reinstalls,
589        restore_planned_verification_checks: operation.planned_verification_checks,
590        restore_planned_phases: operation.planned_phases,
591        restore_phase_count: ordering.phase_count,
592        restore_dependency_free_members: ordering.dependency_free_members,
593        restore_in_group_parent_edges: ordering.in_group_parent_edges,
594        restore_cross_group_parent_edges: ordering.cross_group_parent_edges,
595        manifest_validation_path: input.paths.manifest_validation.display().to_string(),
596        backup_status_path: input.paths.backup_status.display().to_string(),
597        backup_inspection_path: input.paths.backup_inspection.display().to_string(),
598        backup_provenance_path: input.paths.backup_provenance.display().to_string(),
599        backup_integrity_path: input.paths.backup_integrity.display().to_string(),
600        restore_plan_path: input.paths.restore_plan.display().to_string(),
601        preflight_summary_path: input.paths.preflight_summary.display().to_string(),
602    }
603}
604
605/// Inspect manifest and journal agreement without reading artifact bytes.
606pub fn inspect_backup(
607    options: &BackupInspectOptions,
608) -> Result<BackupInspectionReport, BackupCommandError> {
609    let layout = BackupLayout::new(options.dir.clone());
610    layout.inspect().map_err(BackupCommandError::from)
611}
612
613/// Report manifest and journal provenance without reading artifact bytes.
614pub fn backup_provenance(
615    options: &BackupProvenanceOptions,
616) -> Result<BackupProvenanceReport, BackupCommandError> {
617    let layout = BackupLayout::new(options.dir.clone());
618    layout.provenance().map_err(BackupCommandError::from)
619}
620
621// Ensure provenance is internally consistent when requested by scripts.
622fn enforce_provenance_requirements(
623    options: &BackupProvenanceOptions,
624    report: &BackupProvenanceReport,
625) -> Result<(), BackupCommandError> {
626    if !options.require_consistent || (report.backup_id_matches && report.topology_receipts_match) {
627        return Ok(());
628    }
629
630    Err(BackupCommandError::ProvenanceNotConsistent {
631        backup_id: report.backup_id.clone(),
632        backup_id_matches: report.backup_id_matches,
633        topology_receipts_match: report.topology_receipts_match,
634        topology_mismatches: report.topology_receipt_mismatches.len(),
635    })
636}
637
638// Ensure an inspection report is ready for full verification when requested.
639fn enforce_inspection_requirements(
640    options: &BackupInspectOptions,
641    report: &BackupInspectionReport,
642) -> Result<(), BackupCommandError> {
643    if !options.require_ready || report.ready_for_verify {
644        return Ok(());
645    }
646
647    Err(BackupCommandError::InspectionNotReady {
648        backup_id: report.backup_id.clone(),
649        backup_id_matches: report.backup_id_matches,
650        topology_receipts_match: report.topology_receipt_mismatches.is_empty(),
651        journal_complete: report.journal_complete,
652        topology_mismatches: report.topology_receipt_mismatches.len(),
653        missing_artifacts: report.missing_journal_artifacts.len(),
654        unexpected_artifacts: report.unexpected_journal_artifacts.len(),
655        path_mismatches: report.path_mismatches.len(),
656        checksum_mismatches: report.checksum_mismatches.len(),
657    })
658}
659
660/// Summarize a backup journal's resumable state.
661pub fn backup_status(
662    options: &BackupStatusOptions,
663) -> Result<JournalResumeReport, BackupCommandError> {
664    let layout = BackupLayout::new(options.dir.clone());
665    let journal = layout.read_journal()?;
666    Ok(journal.resume_report())
667}
668
669// Ensure a journal status report has no remaining resume work.
670fn ensure_complete_status(report: &JournalResumeReport) -> Result<(), BackupCommandError> {
671    if report.is_complete {
672        return Ok(());
673    }
674
675    Err(BackupCommandError::IncompleteJournal {
676        backup_id: report.backup_id.clone(),
677        total_artifacts: report.total_artifacts,
678        pending_artifacts: report.pending_artifacts,
679    })
680}
681
682// Enforce caller-requested status requirements after the JSON report is written.
683fn enforce_status_requirements(
684    options: &BackupStatusOptions,
685    report: &JournalResumeReport,
686) -> Result<(), BackupCommandError> {
687    if !options.require_complete {
688        return Ok(());
689    }
690
691    ensure_complete_status(report)
692}
693
694/// Verify a backup directory's manifest, journal, and durable artifacts.
695pub fn verify_backup(
696    options: &BackupVerifyOptions,
697) -> Result<BackupIntegrityReport, BackupCommandError> {
698    let layout = BackupLayout::new(options.dir.clone());
699    layout.verify_integrity().map_err(BackupCommandError::from)
700}
701
702// Write the journal status report to stdout or a requested output file.
703fn write_status_report(
704    options: &BackupStatusOptions,
705    report: &JournalResumeReport,
706) -> Result<(), BackupCommandError> {
707    if let Some(path) = &options.out {
708        let data = serde_json::to_vec_pretty(report)?;
709        fs::write(path, data)?;
710        return Ok(());
711    }
712
713    let stdout = io::stdout();
714    let mut handle = stdout.lock();
715    serde_json::to_writer_pretty(&mut handle, report)?;
716    writeln!(handle)?;
717    Ok(())
718}
719
720// Write the inspection report to stdout or a requested output file.
721fn write_inspect_report(
722    options: &BackupInspectOptions,
723    report: &BackupInspectionReport,
724) -> Result<(), BackupCommandError> {
725    if let Some(path) = &options.out {
726        let data = serde_json::to_vec_pretty(report)?;
727        fs::write(path, data)?;
728        return Ok(());
729    }
730
731    let stdout = io::stdout();
732    let mut handle = stdout.lock();
733    serde_json::to_writer_pretty(&mut handle, report)?;
734    writeln!(handle)?;
735    Ok(())
736}
737
738// Write the provenance report to stdout or a requested output file.
739fn write_provenance_report(
740    options: &BackupProvenanceOptions,
741    report: &BackupProvenanceReport,
742) -> Result<(), BackupCommandError> {
743    if let Some(path) = &options.out {
744        let data = serde_json::to_vec_pretty(report)?;
745        fs::write(path, data)?;
746        return Ok(());
747    }
748
749    let stdout = io::stdout();
750    let mut handle = stdout.lock();
751    serde_json::to_writer_pretty(&mut handle, report)?;
752    writeln!(handle)?;
753    Ok(())
754}
755
756// Write the integrity report to stdout or a requested output file.
757fn write_report(
758    options: &BackupVerifyOptions,
759    report: &BackupIntegrityReport,
760) -> Result<(), BackupCommandError> {
761    if let Some(path) = &options.out {
762        let data = serde_json::to_vec_pretty(report)?;
763        fs::write(path, data)?;
764        return Ok(());
765    }
766
767    let stdout = io::stdout();
768    let mut handle = stdout.lock();
769    serde_json::to_writer_pretty(&mut handle, report)?;
770    writeln!(handle)?;
771    Ok(())
772}
773
774// Write one pretty JSON value artifact, creating its parent directory when needed.
775fn write_json_value_file(
776    path: &PathBuf,
777    value: &serde_json::Value,
778) -> Result<(), BackupCommandError> {
779    if let Some(parent) = path.parent() {
780        fs::create_dir_all(parent)?;
781    }
782
783    let data = serde_json::to_vec_pretty(value)?;
784    fs::write(path, data)?;
785    Ok(())
786}
787
788// Build the compact preflight summary emitted after all checks pass.
789fn preflight_summary_value(report: &BackupPreflightReport) -> serde_json::Value {
790    let mut summary = serde_json::Map::new();
791    insert_preflight_source_summary(&mut summary, report);
792    insert_preflight_restore_summary(&mut summary, report);
793    insert_preflight_report_paths(&mut summary, report);
794    serde_json::Value::Object(summary)
795}
796
797// Insert one named JSON value into the compact preflight summary.
798fn insert_summary_value(
799    summary: &mut serde_json::Map<String, serde_json::Value>,
800    key: &'static str,
801    value: serde_json::Value,
802) {
803    summary.insert(key.to_string(), value);
804}
805
806// Insert backup source and validation status fields into the summary.
807fn insert_preflight_source_summary(
808    summary: &mut serde_json::Map<String, serde_json::Value>,
809    report: &BackupPreflightReport,
810) {
811    insert_summary_value(summary, "status", json!(report.status));
812    insert_summary_value(summary, "backup_id", json!(report.backup_id));
813    insert_summary_value(summary, "backup_dir", json!(report.backup_dir));
814    insert_summary_value(
815        summary,
816        "source_environment",
817        json!(report.source_environment),
818    );
819    insert_summary_value(
820        summary,
821        "source_root_canister",
822        json!(report.source_root_canister),
823    );
824    insert_summary_value(summary, "topology_hash", json!(report.topology_hash));
825    insert_summary_value(summary, "mapping_path", json!(report.mapping_path));
826    insert_summary_value(summary, "journal_complete", json!(report.journal_complete));
827    insert_summary_value(
828        summary,
829        "journal_operation_metrics",
830        json!(report.journal_operation_metrics),
831    );
832    insert_summary_value(
833        summary,
834        "inspection_status",
835        json!(report.inspection_status),
836    );
837    insert_summary_value(
838        summary,
839        "provenance_status",
840        json!(report.provenance_status),
841    );
842    insert_summary_value(summary, "backup_id_status", json!(report.backup_id_status));
843    insert_summary_value(
844        summary,
845        "topology_receipts_status",
846        json!(report.topology_receipts_status),
847    );
848    insert_summary_value(
849        summary,
850        "topology_mismatch_count",
851        json!(report.topology_mismatch_count),
852    );
853    insert_summary_value(
854        summary,
855        "integrity_verified",
856        json!(report.integrity_verified),
857    );
858    insert_summary_value(summary, "manifest_members", json!(report.manifest_members));
859    insert_summary_value(
860        summary,
861        "backup_unit_count",
862        json!(report.backup_unit_count),
863    );
864}
865
866// Insert restore planning summary fields into the compact preflight summary.
867fn insert_preflight_restore_summary(
868    summary: &mut serde_json::Map<String, serde_json::Value>,
869    report: &BackupPreflightReport,
870) {
871    insert_summary_value(
872        summary,
873        "restore_plan_members",
874        json!(report.restore_plan_members),
875    );
876    insert_summary_value(
877        summary,
878        "restore_mapping_supplied",
879        json!(report.restore_mapping_supplied),
880    );
881    insert_summary_value(
882        summary,
883        "restore_all_sources_mapped",
884        json!(report.restore_all_sources_mapped),
885    );
886    insert_preflight_restore_identity_summary(summary, report);
887    insert_preflight_restore_readiness_summary(summary, report);
888    insert_preflight_restore_snapshot_summary(summary, report);
889    insert_preflight_restore_verification_summary(summary, report);
890    insert_preflight_restore_operation_summary(summary, report);
891    insert_preflight_restore_ordering_summary(summary, report);
892}
893
894// Insert restore identity summary fields into the compact preflight summary.
895fn insert_preflight_restore_identity_summary(
896    summary: &mut serde_json::Map<String, serde_json::Value>,
897    report: &BackupPreflightReport,
898) {
899    insert_summary_value(
900        summary,
901        "restore_fixed_members",
902        json!(report.restore_fixed_members),
903    );
904    insert_summary_value(
905        summary,
906        "restore_relocatable_members",
907        json!(report.restore_relocatable_members),
908    );
909    insert_summary_value(
910        summary,
911        "restore_in_place_members",
912        json!(report.restore_in_place_members),
913    );
914    insert_summary_value(
915        summary,
916        "restore_mapped_members",
917        json!(report.restore_mapped_members),
918    );
919    insert_summary_value(
920        summary,
921        "restore_remapped_members",
922        json!(report.restore_remapped_members),
923    );
924}
925
926// Insert restore readiness summary fields into the compact preflight summary.
927fn insert_preflight_restore_readiness_summary(
928    summary: &mut serde_json::Map<String, serde_json::Value>,
929    report: &BackupPreflightReport,
930) {
931    insert_summary_value(summary, "restore_ready", json!(report.restore_ready));
932    insert_summary_value(
933        summary,
934        "restore_readiness_reasons",
935        json!(report.restore_readiness_reasons),
936    );
937}
938
939// Insert restore snapshot summary fields into the compact preflight summary.
940fn insert_preflight_restore_snapshot_summary(
941    summary: &mut serde_json::Map<String, serde_json::Value>,
942    report: &BackupPreflightReport,
943) {
944    insert_summary_value(
945        summary,
946        "restore_all_members_have_module_hash",
947        json!(report.restore_all_members_have_module_hash),
948    );
949    insert_summary_value(
950        summary,
951        "restore_all_members_have_wasm_hash",
952        json!(report.restore_all_members_have_wasm_hash),
953    );
954    insert_summary_value(
955        summary,
956        "restore_all_members_have_code_version",
957        json!(report.restore_all_members_have_code_version),
958    );
959    insert_summary_value(
960        summary,
961        "restore_all_members_have_checksum",
962        json!(report.restore_all_members_have_checksum),
963    );
964    insert_summary_value(
965        summary,
966        "restore_members_with_module_hash",
967        json!(report.restore_members_with_module_hash),
968    );
969    insert_summary_value(
970        summary,
971        "restore_members_with_wasm_hash",
972        json!(report.restore_members_with_wasm_hash),
973    );
974    insert_summary_value(
975        summary,
976        "restore_members_with_code_version",
977        json!(report.restore_members_with_code_version),
978    );
979    insert_summary_value(
980        summary,
981        "restore_members_with_checksum",
982        json!(report.restore_members_with_checksum),
983    );
984}
985
986// Insert restore verification summary fields into the compact preflight summary.
987fn insert_preflight_restore_verification_summary(
988    summary: &mut serde_json::Map<String, serde_json::Value>,
989    report: &BackupPreflightReport,
990) {
991    insert_summary_value(
992        summary,
993        "restore_verification_required",
994        json!(report.restore_verification_required),
995    );
996    insert_summary_value(
997        summary,
998        "restore_all_members_have_checks",
999        json!(report.restore_all_members_have_checks),
1000    );
1001    insert_summary_value(
1002        summary,
1003        "restore_fleet_checks",
1004        json!(report.restore_fleet_checks),
1005    );
1006    insert_summary_value(
1007        summary,
1008        "restore_member_check_groups",
1009        json!(report.restore_member_check_groups),
1010    );
1011    insert_summary_value(
1012        summary,
1013        "restore_member_checks",
1014        json!(report.restore_member_checks),
1015    );
1016    insert_summary_value(
1017        summary,
1018        "restore_members_with_checks",
1019        json!(report.restore_members_with_checks),
1020    );
1021    insert_summary_value(
1022        summary,
1023        "restore_total_checks",
1024        json!(report.restore_total_checks),
1025    );
1026}
1027
1028// Insert restore operation summary fields into the compact preflight summary.
1029fn insert_preflight_restore_operation_summary(
1030    summary: &mut serde_json::Map<String, serde_json::Value>,
1031    report: &BackupPreflightReport,
1032) {
1033    insert_summary_value(
1034        summary,
1035        "restore_planned_snapshot_loads",
1036        json!(report.restore_planned_snapshot_loads),
1037    );
1038    insert_summary_value(
1039        summary,
1040        "restore_planned_code_reinstalls",
1041        json!(report.restore_planned_code_reinstalls),
1042    );
1043    insert_summary_value(
1044        summary,
1045        "restore_planned_verification_checks",
1046        json!(report.restore_planned_verification_checks),
1047    );
1048    insert_summary_value(
1049        summary,
1050        "restore_planned_phases",
1051        json!(report.restore_planned_phases),
1052    );
1053}
1054
1055// Insert restore ordering summary fields into the compact preflight summary.
1056fn insert_preflight_restore_ordering_summary(
1057    summary: &mut serde_json::Map<String, serde_json::Value>,
1058    report: &BackupPreflightReport,
1059) {
1060    insert_summary_value(
1061        summary,
1062        "restore_phase_count",
1063        json!(report.restore_phase_count),
1064    );
1065    insert_summary_value(
1066        summary,
1067        "restore_dependency_free_members",
1068        json!(report.restore_dependency_free_members),
1069    );
1070    insert_summary_value(
1071        summary,
1072        "restore_in_group_parent_edges",
1073        json!(report.restore_in_group_parent_edges),
1074    );
1075    insert_summary_value(
1076        summary,
1077        "restore_cross_group_parent_edges",
1078        json!(report.restore_cross_group_parent_edges),
1079    );
1080}
1081
1082// Insert generated report paths into the compact preflight summary.
1083fn insert_preflight_report_paths(
1084    summary: &mut serde_json::Map<String, serde_json::Value>,
1085    report: &BackupPreflightReport,
1086) {
1087    insert_summary_value(
1088        summary,
1089        "manifest_validation_path",
1090        json!(report.manifest_validation_path),
1091    );
1092    insert_summary_value(
1093        summary,
1094        "backup_status_path",
1095        json!(report.backup_status_path),
1096    );
1097    insert_summary_value(
1098        summary,
1099        "backup_inspection_path",
1100        json!(report.backup_inspection_path),
1101    );
1102    insert_summary_value(
1103        summary,
1104        "backup_provenance_path",
1105        json!(report.backup_provenance_path),
1106    );
1107    insert_summary_value(
1108        summary,
1109        "backup_integrity_path",
1110        json!(report.backup_integrity_path),
1111    );
1112    insert_summary_value(
1113        summary,
1114        "restore_plan_path",
1115        json!(report.restore_plan_path),
1116    );
1117    insert_summary_value(
1118        summary,
1119        "preflight_summary_path",
1120        json!(report.preflight_summary_path),
1121    );
1122}
1123
1124// Build the same compact validation summary emitted by manifest validation.
1125fn manifest_validation_summary(manifest: &FleetBackupManifest) -> serde_json::Value {
1126    json!({
1127        "status": "valid",
1128        "backup_id": manifest.backup_id,
1129        "members": manifest.fleet.members.len(),
1130        "backup_unit_count": manifest.consistency.backup_units.len(),
1131        "consistency_mode": consistency_mode_name(&manifest.consistency.mode),
1132        "topology_hash": manifest.fleet.topology_hash,
1133        "topology_hash_algorithm": manifest.fleet.topology_hash_algorithm,
1134        "topology_hash_input": manifest.fleet.topology_hash_input,
1135        "topology_validation_status": "validated",
1136        "backup_unit_kinds": backup_unit_kind_counts(manifest),
1137        "backup_units": manifest
1138            .consistency
1139            .backup_units
1140            .iter()
1141            .map(|unit| json!({
1142                "unit_id": unit.unit_id,
1143                "kind": backup_unit_kind_name(&unit.kind),
1144                "role_count": unit.roles.len(),
1145                "dependency_count": unit.dependency_closure.len(),
1146                "topology_validation": unit.topology_validation,
1147            }))
1148            .collect::<Vec<_>>(),
1149    })
1150}
1151
1152// Count backup units by stable serialized kind name.
1153fn backup_unit_kind_counts(manifest: &FleetBackupManifest) -> serde_json::Value {
1154    let mut whole_fleet = 0;
1155    let mut control_plane_subset = 0;
1156    let mut subtree_rooted = 0;
1157    let mut flat = 0;
1158    for unit in &manifest.consistency.backup_units {
1159        match &unit.kind {
1160            BackupUnitKind::WholeFleet => whole_fleet += 1,
1161            BackupUnitKind::ControlPlaneSubset => control_plane_subset += 1,
1162            BackupUnitKind::SubtreeRooted => subtree_rooted += 1,
1163            BackupUnitKind::Flat => flat += 1,
1164        }
1165    }
1166
1167    json!({
1168        "whole_fleet": whole_fleet,
1169        "control_plane_subset": control_plane_subset,
1170        "subtree_rooted": subtree_rooted,
1171        "flat": flat,
1172    })
1173}
1174
1175// Return the stable serialized name for a consistency mode.
1176const fn consistency_mode_name(mode: &ConsistencyMode) -> &'static str {
1177    match mode {
1178        ConsistencyMode::CrashConsistent => "crash-consistent",
1179        ConsistencyMode::QuiescedUnit => "quiesced-unit",
1180    }
1181}
1182
1183// Return the stable serialized name for a backup unit kind.
1184const fn backup_unit_kind_name(kind: &BackupUnitKind) -> &'static str {
1185    match kind {
1186        BackupUnitKind::WholeFleet => "whole-fleet",
1187        BackupUnitKind::ControlPlaneSubset => "control-plane-subset",
1188        BackupUnitKind::SubtreeRooted => "subtree-rooted",
1189        BackupUnitKind::Flat => "flat",
1190    }
1191}
1192
1193// Return the stable summary status for inspection readiness.
1194const fn readiness_status(ready: bool) -> &'static str {
1195    if ready { "ready" } else { "not-ready" }
1196}
1197
1198// Return the stable summary status for provenance consistency.
1199const fn consistency_status(consistent: bool) -> &'static str {
1200    if consistent {
1201        "consistent"
1202    } else {
1203        "inconsistent"
1204    }
1205}
1206
1207// Return the stable summary status for equality checks.
1208const fn match_status(matches: bool) -> &'static str {
1209    if matches { "matched" } else { "mismatched" }
1210}
1211
1212// Read and decode an optional source-to-target restore mapping from disk.
1213fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, BackupCommandError> {
1214    let data = fs::read_to_string(path)?;
1215    serde_json::from_str(&data).map_err(BackupCommandError::from)
1216}
1217
1218// Read the next required option value.
1219fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, BackupCommandError>
1220where
1221    I: Iterator<Item = OsString>,
1222{
1223    args.next()
1224        .and_then(|value| value.into_string().ok())
1225        .ok_or(BackupCommandError::MissingValue(option))
1226}
1227
1228// Return backup command usage text.
1229const fn usage() -> &'static str {
1230    "usage: canic backup preflight --dir <backup-dir> --out-dir <dir> [--mapping <file>]\n       canic backup inspect --dir <backup-dir> [--out <file>] [--require-ready]\n       canic backup provenance --dir <backup-dir> [--out <file>] [--require-consistent]\n       canic backup status --dir <backup-dir> [--out <file>] [--require-complete]\n       canic backup verify --dir <backup-dir> [--out <file>]"
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235    use super::*;
1236    use canic_backup::{
1237        artifacts::ArtifactChecksum,
1238        journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
1239        manifest::{
1240            BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetBackupManifest,
1241            FleetMember, FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
1242            VerificationCheck, VerificationPlan,
1243        },
1244    };
1245    use std::{
1246        fs,
1247        path::Path,
1248        time::{SystemTime, UNIX_EPOCH},
1249    };
1250
1251    const ROOT: &str = "aaaaa-aa";
1252    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1253
1254    // Ensure backup preflight options parse the intended command shape.
1255    #[test]
1256    fn parses_backup_preflight_options() {
1257        let options = BackupPreflightOptions::parse([
1258            OsString::from("--dir"),
1259            OsString::from("backups/run"),
1260            OsString::from("--out-dir"),
1261            OsString::from("reports/run"),
1262            OsString::from("--mapping"),
1263            OsString::from("mapping.json"),
1264        ])
1265        .expect("parse options");
1266
1267        assert_eq!(options.dir, PathBuf::from("backups/run"));
1268        assert_eq!(options.out_dir, PathBuf::from("reports/run"));
1269        assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
1270    }
1271
1272    // Ensure preflight writes the standard no-mutation report bundle.
1273    #[test]
1274    fn backup_preflight_writes_standard_reports() {
1275        let root = temp_dir("canic-cli-backup-preflight");
1276        let out_dir = root.join("reports");
1277        let backup_dir = root.join("backup");
1278        let layout = BackupLayout::new(backup_dir.clone());
1279        let checksum = write_artifact(&backup_dir, b"root artifact");
1280
1281        layout
1282            .write_manifest(&valid_manifest())
1283            .expect("write manifest");
1284        layout
1285            .write_journal(&journal_with_checksum(checksum.hash))
1286            .expect("write journal");
1287
1288        let options = BackupPreflightOptions {
1289            dir: backup_dir,
1290            out_dir: out_dir.clone(),
1291            mapping: None,
1292        };
1293        let report = backup_preflight(&options).expect("run preflight");
1294
1295        assert_eq!(report.status, "ready");
1296        assert_eq!(report.backup_id, "backup-test");
1297        assert_eq!(report.source_environment, "local");
1298        assert_eq!(report.source_root_canister, ROOT);
1299        assert_eq!(report.topology_hash, HASH);
1300        assert_eq!(report.mapping_path, None);
1301        assert!(report.journal_complete);
1302        assert_eq!(
1303            report.journal_operation_metrics,
1304            DownloadOperationMetrics::default()
1305        );
1306        assert_eq!(report.inspection_status, "ready");
1307        assert_eq!(report.provenance_status, "consistent");
1308        assert_eq!(report.backup_id_status, "matched");
1309        assert_eq!(report.topology_receipts_status, "matched");
1310        assert_eq!(report.topology_mismatch_count, 0);
1311        assert!(report.integrity_verified);
1312        assert_eq!(report.manifest_members, 1);
1313        assert_eq!(report.backup_unit_count, 1);
1314        assert_eq!(report.restore_plan_members, 1);
1315        assert!(!report.restore_mapping_supplied);
1316        assert!(!report.restore_all_sources_mapped);
1317        assert_preflight_report_restore_counts(&report);
1318        assert!(out_dir.join("manifest-validation.json").exists());
1319        assert!(out_dir.join("backup-status.json").exists());
1320        assert!(out_dir.join("backup-inspection.json").exists());
1321        assert!(out_dir.join("backup-provenance.json").exists());
1322        assert!(out_dir.join("backup-integrity.json").exists());
1323        assert!(out_dir.join("restore-plan.json").exists());
1324        assert!(out_dir.join("preflight-summary.json").exists());
1325
1326        let summary: serde_json::Value = serde_json::from_slice(
1327            &fs::read(out_dir.join("preflight-summary.json")).expect("read summary"),
1328        )
1329        .expect("decode summary");
1330        let manifest_validation: serde_json::Value = serde_json::from_slice(
1331            &fs::read(out_dir.join("manifest-validation.json")).expect("read manifest summary"),
1332        )
1333        .expect("decode manifest summary");
1334
1335        fs::remove_dir_all(root).expect("remove temp root");
1336        assert_preflight_summary_matches_report(&summary, &report);
1337        assert_eq!(manifest_validation["backup_unit_count"], 1);
1338        assert_eq!(manifest_validation["consistency_mode"], "crash-consistent");
1339        assert_eq!(
1340            manifest_validation["topology_validation_status"],
1341            "validated"
1342        );
1343        assert_eq!(
1344            manifest_validation["backup_unit_kinds"]["subtree_rooted"],
1345            1
1346        );
1347        assert_eq!(
1348            manifest_validation["backup_units"][0]["kind"],
1349            "subtree-rooted"
1350        );
1351    }
1352
1353    // Verify restore summary counts copied out of the generated restore plan.
1354    fn assert_preflight_report_restore_counts(report: &BackupPreflightReport) {
1355        assert_eq!(report.restore_fixed_members, 1);
1356        assert_eq!(report.restore_relocatable_members, 0);
1357        assert_eq!(report.restore_in_place_members, 1);
1358        assert_eq!(report.restore_mapped_members, 0);
1359        assert_eq!(report.restore_remapped_members, 0);
1360        assert!(!report.restore_ready);
1361        assert_eq!(
1362            report.restore_readiness_reasons,
1363            [
1364                "missing-module-hash",
1365                "missing-wasm-hash",
1366                "missing-snapshot-checksum"
1367            ]
1368        );
1369        assert!(!report.restore_all_members_have_module_hash);
1370        assert!(!report.restore_all_members_have_wasm_hash);
1371        assert!(report.restore_all_members_have_code_version);
1372        assert!(!report.restore_all_members_have_checksum);
1373        assert_eq!(report.restore_members_with_module_hash, 0);
1374        assert_eq!(report.restore_members_with_wasm_hash, 0);
1375        assert_eq!(report.restore_members_with_code_version, 1);
1376        assert_eq!(report.restore_members_with_checksum, 0);
1377        assert!(report.restore_verification_required);
1378        assert!(report.restore_all_members_have_checks);
1379        assert_eq!(report.restore_fleet_checks, 0);
1380        assert_eq!(report.restore_member_check_groups, 0);
1381        assert_eq!(report.restore_member_checks, 1);
1382        assert_eq!(report.restore_members_with_checks, 1);
1383        assert_eq!(report.restore_total_checks, 1);
1384        assert_eq!(report.restore_planned_snapshot_loads, 1);
1385        assert_eq!(report.restore_planned_code_reinstalls, 1);
1386        assert_eq!(report.restore_planned_verification_checks, 1);
1387        assert_eq!(report.restore_planned_phases, 1);
1388        assert_eq!(report.restore_phase_count, 1);
1389        assert_eq!(report.restore_dependency_free_members, 1);
1390        assert_eq!(report.restore_in_group_parent_edges, 0);
1391        assert_eq!(report.restore_cross_group_parent_edges, 0);
1392    }
1393
1394    // Compare preflight summary JSON with the in-memory report.
1395    fn assert_preflight_summary_matches_report(
1396        summary: &serde_json::Value,
1397        report: &BackupPreflightReport,
1398    ) {
1399        assert_preflight_source_summary_matches_report(summary, report);
1400        assert_preflight_restore_identity_summary_matches_report(summary, report);
1401        assert_preflight_restore_readiness_summary_matches_report(summary, report);
1402        assert_preflight_restore_snapshot_summary_matches_report(summary, report);
1403        assert_preflight_restore_verification_summary_matches_report(summary, report);
1404        assert_preflight_restore_operation_summary_matches_report(summary, report);
1405        assert_preflight_restore_ordering_summary_matches_report(summary, report);
1406        assert_preflight_path_summary_matches_report(summary, report);
1407    }
1408
1409    // Compare source and validation summary JSON fields with the in-memory report.
1410    fn assert_preflight_source_summary_matches_report(
1411        summary: &serde_json::Value,
1412        report: &BackupPreflightReport,
1413    ) {
1414        assert_eq!(summary["status"], report.status);
1415        assert_eq!(summary["backup_id"], report.backup_id);
1416        assert_eq!(summary["source_environment"], report.source_environment);
1417        assert_eq!(summary["source_root_canister"], report.source_root_canister);
1418        assert_eq!(summary["topology_hash"], report.topology_hash);
1419        assert_eq!(summary["journal_complete"], report.journal_complete);
1420        assert_eq!(
1421            summary["journal_operation_metrics"],
1422            json!(report.journal_operation_metrics)
1423        );
1424        assert_eq!(summary["inspection_status"], report.inspection_status);
1425        assert_eq!(summary["provenance_status"], report.provenance_status);
1426        assert_eq!(summary["backup_id_status"], report.backup_id_status);
1427        assert_eq!(
1428            summary["topology_receipts_status"],
1429            report.topology_receipts_status
1430        );
1431        assert_eq!(
1432            summary["topology_mismatch_count"],
1433            report.topology_mismatch_count
1434        );
1435        assert_eq!(summary["integrity_verified"], report.integrity_verified);
1436        assert_eq!(summary["manifest_members"], report.manifest_members);
1437        assert_eq!(summary["backup_unit_count"], report.backup_unit_count);
1438        assert_eq!(summary["restore_plan_members"], report.restore_plan_members);
1439        assert_eq!(
1440            summary["restore_mapping_supplied"],
1441            report.restore_mapping_supplied
1442        );
1443        assert_eq!(
1444            summary["restore_all_sources_mapped"],
1445            report.restore_all_sources_mapped
1446        );
1447    }
1448
1449    // Compare restore identity summary JSON fields with the in-memory report.
1450    fn assert_preflight_restore_identity_summary_matches_report(
1451        summary: &serde_json::Value,
1452        report: &BackupPreflightReport,
1453    ) {
1454        assert_eq!(
1455            summary["restore_fixed_members"],
1456            report.restore_fixed_members
1457        );
1458        assert_eq!(
1459            summary["restore_relocatable_members"],
1460            report.restore_relocatable_members
1461        );
1462        assert_eq!(
1463            summary["restore_in_place_members"],
1464            report.restore_in_place_members
1465        );
1466        assert_eq!(
1467            summary["restore_mapped_members"],
1468            report.restore_mapped_members
1469        );
1470        assert_eq!(
1471            summary["restore_remapped_members"],
1472            report.restore_remapped_members
1473        );
1474    }
1475
1476    // Compare restore readiness summary JSON fields with the in-memory report.
1477    fn assert_preflight_restore_readiness_summary_matches_report(
1478        summary: &serde_json::Value,
1479        report: &BackupPreflightReport,
1480    ) {
1481        assert_eq!(summary["restore_ready"], report.restore_ready);
1482        assert_eq!(
1483            summary["restore_readiness_reasons"],
1484            json!(report.restore_readiness_reasons)
1485        );
1486    }
1487
1488    // Compare restore snapshot summary JSON fields with the in-memory report.
1489    fn assert_preflight_restore_snapshot_summary_matches_report(
1490        summary: &serde_json::Value,
1491        report: &BackupPreflightReport,
1492    ) {
1493        assert_eq!(
1494            summary["restore_all_members_have_module_hash"],
1495            report.restore_all_members_have_module_hash
1496        );
1497        assert_eq!(
1498            summary["restore_all_members_have_wasm_hash"],
1499            report.restore_all_members_have_wasm_hash
1500        );
1501        assert_eq!(
1502            summary["restore_all_members_have_code_version"],
1503            report.restore_all_members_have_code_version
1504        );
1505        assert_eq!(
1506            summary["restore_all_members_have_checksum"],
1507            report.restore_all_members_have_checksum
1508        );
1509        assert_eq!(
1510            summary["restore_members_with_module_hash"],
1511            report.restore_members_with_module_hash
1512        );
1513        assert_eq!(
1514            summary["restore_members_with_wasm_hash"],
1515            report.restore_members_with_wasm_hash
1516        );
1517        assert_eq!(
1518            summary["restore_members_with_code_version"],
1519            report.restore_members_with_code_version
1520        );
1521        assert_eq!(
1522            summary["restore_members_with_checksum"],
1523            report.restore_members_with_checksum
1524        );
1525    }
1526
1527    // Compare restore verification summary JSON fields with the in-memory report.
1528    fn assert_preflight_restore_verification_summary_matches_report(
1529        summary: &serde_json::Value,
1530        report: &BackupPreflightReport,
1531    ) {
1532        assert_eq!(
1533            summary["restore_verification_required"],
1534            report.restore_verification_required
1535        );
1536        assert_eq!(
1537            summary["restore_all_members_have_checks"],
1538            report.restore_all_members_have_checks
1539        );
1540        assert_eq!(summary["restore_fleet_checks"], report.restore_fleet_checks);
1541        assert_eq!(
1542            summary["restore_member_check_groups"],
1543            report.restore_member_check_groups
1544        );
1545        assert_eq!(
1546            summary["restore_member_checks"],
1547            report.restore_member_checks
1548        );
1549        assert_eq!(
1550            summary["restore_members_with_checks"],
1551            report.restore_members_with_checks
1552        );
1553        assert_eq!(summary["restore_total_checks"], report.restore_total_checks);
1554    }
1555
1556    // Compare restore operation summary JSON fields with the in-memory report.
1557    fn assert_preflight_restore_operation_summary_matches_report(
1558        summary: &serde_json::Value,
1559        report: &BackupPreflightReport,
1560    ) {
1561        assert_eq!(
1562            summary["restore_planned_snapshot_loads"],
1563            report.restore_planned_snapshot_loads
1564        );
1565        assert_eq!(
1566            summary["restore_planned_code_reinstalls"],
1567            report.restore_planned_code_reinstalls
1568        );
1569        assert_eq!(
1570            summary["restore_planned_verification_checks"],
1571            report.restore_planned_verification_checks
1572        );
1573        assert_eq!(
1574            summary["restore_planned_phases"],
1575            report.restore_planned_phases
1576        );
1577    }
1578
1579    // Compare restore ordering summary JSON fields with the in-memory report.
1580    fn assert_preflight_restore_ordering_summary_matches_report(
1581        summary: &serde_json::Value,
1582        report: &BackupPreflightReport,
1583    ) {
1584        assert_eq!(summary["restore_phase_count"], report.restore_phase_count);
1585        assert_eq!(
1586            summary["restore_dependency_free_members"],
1587            report.restore_dependency_free_members
1588        );
1589        assert_eq!(
1590            summary["restore_in_group_parent_edges"],
1591            report.restore_in_group_parent_edges
1592        );
1593        assert_eq!(
1594            summary["restore_cross_group_parent_edges"],
1595            report.restore_cross_group_parent_edges
1596        );
1597    }
1598
1599    // Compare generated report path JSON fields with the in-memory report.
1600    fn assert_preflight_path_summary_matches_report(
1601        summary: &serde_json::Value,
1602        report: &BackupPreflightReport,
1603    ) {
1604        assert_eq!(
1605            summary["manifest_validation_path"],
1606            report.manifest_validation_path
1607        );
1608        assert_eq!(summary["backup_status_path"], report.backup_status_path);
1609        assert_eq!(
1610            summary["backup_inspection_path"],
1611            report.backup_inspection_path
1612        );
1613        assert_eq!(
1614            summary["backup_provenance_path"],
1615            report.backup_provenance_path
1616        );
1617        assert_eq!(
1618            summary["backup_integrity_path"],
1619            report.backup_integrity_path
1620        );
1621        assert_eq!(summary["restore_plan_path"], report.restore_plan_path);
1622        assert_eq!(
1623            summary["preflight_summary_path"],
1624            report.preflight_summary_path
1625        );
1626    }
1627
1628    // Ensure preflight stops on incomplete journals before claiming readiness.
1629    #[test]
1630    fn backup_preflight_rejects_incomplete_journal() {
1631        let root = temp_dir("canic-cli-backup-preflight-incomplete");
1632        let out_dir = root.join("reports");
1633        let backup_dir = root.join("backup");
1634        let layout = BackupLayout::new(backup_dir.clone());
1635
1636        layout
1637            .write_manifest(&valid_manifest())
1638            .expect("write manifest");
1639        layout
1640            .write_journal(&created_journal())
1641            .expect("write journal");
1642
1643        let options = BackupPreflightOptions {
1644            dir: backup_dir,
1645            out_dir,
1646            mapping: None,
1647        };
1648
1649        let err = backup_preflight(&options).expect_err("incomplete journal should fail");
1650
1651        fs::remove_dir_all(root).expect("remove temp root");
1652        assert!(matches!(
1653            err,
1654            BackupCommandError::IncompleteJournal {
1655                pending_artifacts: 1,
1656                total_artifacts: 1,
1657                ..
1658            }
1659        ));
1660    }
1661
1662    // Ensure backup verification options parse the intended command shape.
1663    #[test]
1664    fn parses_backup_verify_options() {
1665        let options = BackupVerifyOptions::parse([
1666            OsString::from("--dir"),
1667            OsString::from("backups/run"),
1668            OsString::from("--out"),
1669            OsString::from("report.json"),
1670        ])
1671        .expect("parse options");
1672
1673        assert_eq!(options.dir, PathBuf::from("backups/run"));
1674        assert_eq!(options.out, Some(PathBuf::from("report.json")));
1675    }
1676
1677    // Ensure backup inspection options parse the intended command shape.
1678    #[test]
1679    fn parses_backup_inspect_options() {
1680        let options = BackupInspectOptions::parse([
1681            OsString::from("--dir"),
1682            OsString::from("backups/run"),
1683            OsString::from("--out"),
1684            OsString::from("inspect.json"),
1685            OsString::from("--require-ready"),
1686        ])
1687        .expect("parse options");
1688
1689        assert_eq!(options.dir, PathBuf::from("backups/run"));
1690        assert_eq!(options.out, Some(PathBuf::from("inspect.json")));
1691        assert!(options.require_ready);
1692    }
1693
1694    // Ensure backup provenance options parse the intended command shape.
1695    #[test]
1696    fn parses_backup_provenance_options() {
1697        let options = BackupProvenanceOptions::parse([
1698            OsString::from("--dir"),
1699            OsString::from("backups/run"),
1700            OsString::from("--out"),
1701            OsString::from("provenance.json"),
1702            OsString::from("--require-consistent"),
1703        ])
1704        .expect("parse options");
1705
1706        assert_eq!(options.dir, PathBuf::from("backups/run"));
1707        assert_eq!(options.out, Some(PathBuf::from("provenance.json")));
1708        assert!(options.require_consistent);
1709    }
1710
1711    // Ensure backup status options parse the intended command shape.
1712    #[test]
1713    fn parses_backup_status_options() {
1714        let options = BackupStatusOptions::parse([
1715            OsString::from("--dir"),
1716            OsString::from("backups/run"),
1717            OsString::from("--out"),
1718            OsString::from("status.json"),
1719            OsString::from("--require-complete"),
1720        ])
1721        .expect("parse options");
1722
1723        assert_eq!(options.dir, PathBuf::from("backups/run"));
1724        assert_eq!(options.out, Some(PathBuf::from("status.json")));
1725        assert!(options.require_complete);
1726    }
1727
1728    // Ensure backup status reads the journal and reports resume actions.
1729    #[test]
1730    fn backup_status_reads_journal_resume_report() {
1731        let root = temp_dir("canic-cli-backup-status");
1732        let layout = BackupLayout::new(root.clone());
1733        layout
1734            .write_journal(&journal_with_checksum(HASH.to_string()))
1735            .expect("write journal");
1736
1737        let options = BackupStatusOptions {
1738            dir: root.clone(),
1739            out: None,
1740            require_complete: false,
1741        };
1742        let report = backup_status(&options).expect("read backup status");
1743
1744        fs::remove_dir_all(root).expect("remove temp root");
1745        assert_eq!(report.backup_id, "backup-test");
1746        assert_eq!(report.total_artifacts, 1);
1747        assert!(report.is_complete);
1748        assert_eq!(report.pending_artifacts, 0);
1749        assert_eq!(report.counts.skip, 1);
1750    }
1751
1752    // Ensure backup inspection reports manifest and journal agreement.
1753    #[test]
1754    fn inspect_backup_reads_layout_metadata() {
1755        let root = temp_dir("canic-cli-backup-inspect");
1756        let layout = BackupLayout::new(root.clone());
1757
1758        layout
1759            .write_manifest(&valid_manifest())
1760            .expect("write manifest");
1761        layout
1762            .write_journal(&journal_with_checksum(HASH.to_string()))
1763            .expect("write journal");
1764
1765        let options = BackupInspectOptions {
1766            dir: root.clone(),
1767            out: None,
1768            require_ready: false,
1769        };
1770        let report = inspect_backup(&options).expect("inspect backup");
1771
1772        fs::remove_dir_all(root).expect("remove temp root");
1773        assert_eq!(report.backup_id, "backup-test");
1774        assert!(report.backup_id_matches);
1775        assert!(report.journal_complete);
1776        assert!(report.ready_for_verify);
1777        assert!(report.topology_receipt_mismatches.is_empty());
1778        assert_eq!(report.matched_artifacts, 1);
1779    }
1780
1781    // Ensure backup provenance reports manifest and journal audit metadata.
1782    #[test]
1783    fn backup_provenance_reads_layout_metadata() {
1784        let root = temp_dir("canic-cli-backup-provenance");
1785        let layout = BackupLayout::new(root.clone());
1786
1787        layout
1788            .write_manifest(&valid_manifest())
1789            .expect("write manifest");
1790        layout
1791            .write_journal(&journal_with_checksum(HASH.to_string()))
1792            .expect("write journal");
1793
1794        let options = BackupProvenanceOptions {
1795            dir: root.clone(),
1796            out: None,
1797            require_consistent: false,
1798        };
1799        let report = backup_provenance(&options).expect("read provenance");
1800
1801        fs::remove_dir_all(root).expect("remove temp root");
1802        assert_eq!(report.backup_id, "backup-test");
1803        assert!(report.backup_id_matches);
1804        assert_eq!(report.source_environment, "local");
1805        assert_eq!(report.discovery_topology_hash, HASH);
1806        assert!(report.topology_receipts_match);
1807        assert!(report.topology_receipt_mismatches.is_empty());
1808        assert_eq!(report.backup_unit_count, 1);
1809        assert_eq!(report.member_count, 1);
1810        assert_eq!(report.backup_units[0].kind, "subtree-rooted");
1811        assert_eq!(report.members[0].canister_id, ROOT);
1812        assert_eq!(report.members[0].snapshot_id, "root-snapshot");
1813        assert_eq!(report.members[0].journal_state, Some("Durable".to_string()));
1814    }
1815
1816    // Ensure require-consistent accepts matching provenance reports.
1817    #[test]
1818    fn require_consistent_accepts_matching_provenance() {
1819        let options = BackupProvenanceOptions {
1820            dir: PathBuf::from("unused"),
1821            out: None,
1822            require_consistent: true,
1823        };
1824        let report = ready_provenance_report();
1825
1826        enforce_provenance_requirements(&options, &report)
1827            .expect("matching provenance should pass");
1828    }
1829
1830    // Ensure require-consistent rejects backup ID or topology receipt drift.
1831    #[test]
1832    fn require_consistent_rejects_provenance_drift() {
1833        let options = BackupProvenanceOptions {
1834            dir: PathBuf::from("unused"),
1835            out: None,
1836            require_consistent: true,
1837        };
1838        let mut report = ready_provenance_report();
1839        report.backup_id_matches = false;
1840        report.journal_backup_id = "other-backup".to_string();
1841        report.topology_receipts_match = false;
1842        report.topology_receipt_mismatches.push(
1843            canic_backup::persistence::TopologyReceiptMismatch {
1844                field: "pre_snapshot_topology_hash".to_string(),
1845                manifest: HASH.to_string(),
1846                journal: None,
1847            },
1848        );
1849
1850        let err = enforce_provenance_requirements(&options, &report)
1851            .expect_err("provenance drift should fail");
1852
1853        assert!(matches!(
1854            err,
1855            BackupCommandError::ProvenanceNotConsistent {
1856                backup_id_matches: false,
1857                topology_receipts_match: false,
1858                topology_mismatches: 1,
1859                ..
1860            }
1861        ));
1862    }
1863
1864    // Ensure require-ready accepts inspection reports ready for verification.
1865    #[test]
1866    fn require_ready_accepts_ready_inspection() {
1867        let options = BackupInspectOptions {
1868            dir: PathBuf::from("unused"),
1869            out: None,
1870            require_ready: true,
1871        };
1872        let report = ready_inspection_report();
1873
1874        enforce_inspection_requirements(&options, &report).expect("ready inspection should pass");
1875    }
1876
1877    // Ensure require-ready rejects inspection reports with metadata drift.
1878    #[test]
1879    fn require_ready_rejects_unready_inspection() {
1880        let options = BackupInspectOptions {
1881            dir: PathBuf::from("unused"),
1882            out: None,
1883            require_ready: true,
1884        };
1885        let mut report = ready_inspection_report();
1886        report.ready_for_verify = false;
1887        report
1888            .path_mismatches
1889            .push(canic_backup::persistence::ArtifactPathMismatch {
1890                canister_id: ROOT.to_string(),
1891                snapshot_id: "root-snapshot".to_string(),
1892                manifest: "artifacts/root".to_string(),
1893                journal: "artifacts/other-root".to_string(),
1894            });
1895
1896        let err = enforce_inspection_requirements(&options, &report)
1897            .expect_err("unready inspection should fail");
1898
1899        assert!(matches!(
1900            err,
1901            BackupCommandError::InspectionNotReady {
1902                path_mismatches: 1,
1903                ..
1904            }
1905        ));
1906    }
1907
1908    // Ensure require-ready rejects topology receipt drift.
1909    #[test]
1910    fn require_ready_rejects_topology_receipt_drift() {
1911        let options = BackupInspectOptions {
1912            dir: PathBuf::from("unused"),
1913            out: None,
1914            require_ready: true,
1915        };
1916        let mut report = ready_inspection_report();
1917        report.ready_for_verify = false;
1918        report.topology_receipt_mismatches.push(
1919            canic_backup::persistence::TopologyReceiptMismatch {
1920                field: "discovery_topology_hash".to_string(),
1921                manifest: HASH.to_string(),
1922                journal: None,
1923            },
1924        );
1925
1926        let err = enforce_inspection_requirements(&options, &report)
1927            .expect_err("topology receipt drift should fail");
1928
1929        assert!(matches!(
1930            err,
1931            BackupCommandError::InspectionNotReady {
1932                topology_receipts_match: false,
1933                topology_mismatches: 1,
1934                ..
1935            }
1936        ));
1937    }
1938
1939    // Ensure require-complete accepts already durable backup journals.
1940    #[test]
1941    fn require_complete_accepts_complete_status() {
1942        let options = BackupStatusOptions {
1943            dir: PathBuf::from("unused"),
1944            out: None,
1945            require_complete: true,
1946        };
1947        let report = journal_with_checksum(HASH.to_string()).resume_report();
1948
1949        enforce_status_requirements(&options, &report).expect("complete status should pass");
1950    }
1951
1952    // Ensure require-complete rejects journals that still need resume work.
1953    #[test]
1954    fn require_complete_rejects_incomplete_status() {
1955        let options = BackupStatusOptions {
1956            dir: PathBuf::from("unused"),
1957            out: None,
1958            require_complete: true,
1959        };
1960        let report = created_journal().resume_report();
1961
1962        let err = enforce_status_requirements(&options, &report)
1963            .expect_err("incomplete status should fail");
1964
1965        assert!(matches!(
1966            err,
1967            BackupCommandError::IncompleteJournal {
1968                pending_artifacts: 1,
1969                total_artifacts: 1,
1970                ..
1971            }
1972        ));
1973    }
1974
1975    // Ensure the CLI verification path reads a layout and returns an integrity report.
1976    #[test]
1977    fn verify_backup_reads_layout_and_artifacts() {
1978        let root = temp_dir("canic-cli-backup-verify");
1979        let layout = BackupLayout::new(root.clone());
1980        let checksum = write_artifact(&root, b"root artifact");
1981
1982        layout
1983            .write_manifest(&valid_manifest())
1984            .expect("write manifest");
1985        layout
1986            .write_journal(&journal_with_checksum(checksum.hash.clone()))
1987            .expect("write journal");
1988
1989        let options = BackupVerifyOptions {
1990            dir: root.clone(),
1991            out: None,
1992        };
1993        let report = verify_backup(&options).expect("verify backup");
1994
1995        fs::remove_dir_all(root).expect("remove temp root");
1996        assert_eq!(report.backup_id, "backup-test");
1997        assert!(report.verified);
1998        assert_eq!(report.durable_artifacts, 1);
1999        assert_eq!(report.artifacts[0].checksum, checksum.hash);
2000    }
2001
2002    // Build one valid manifest for CLI verification tests.
2003    fn valid_manifest() -> FleetBackupManifest {
2004        FleetBackupManifest {
2005            manifest_version: 1,
2006            backup_id: "backup-test".to_string(),
2007            created_at: "2026-05-03T00:00:00Z".to_string(),
2008            tool: ToolMetadata {
2009                name: "canic".to_string(),
2010                version: "0.30.3".to_string(),
2011            },
2012            source: SourceMetadata {
2013                environment: "local".to_string(),
2014                root_canister: ROOT.to_string(),
2015            },
2016            consistency: ConsistencySection {
2017                mode: ConsistencyMode::CrashConsistent,
2018                backup_units: vec![BackupUnit {
2019                    unit_id: "fleet".to_string(),
2020                    kind: BackupUnitKind::SubtreeRooted,
2021                    roles: vec!["root".to_string()],
2022                    consistency_reason: None,
2023                    dependency_closure: Vec::new(),
2024                    topology_validation: "subtree-closed".to_string(),
2025                    quiescence_strategy: None,
2026                }],
2027            },
2028            fleet: FleetSection {
2029                topology_hash_algorithm: "sha256".to_string(),
2030                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
2031                discovery_topology_hash: HASH.to_string(),
2032                pre_snapshot_topology_hash: HASH.to_string(),
2033                topology_hash: HASH.to_string(),
2034                members: vec![fleet_member()],
2035            },
2036            verification: VerificationPlan::default(),
2037        }
2038    }
2039
2040    // Build one valid manifest member.
2041    fn fleet_member() -> FleetMember {
2042        FleetMember {
2043            role: "root".to_string(),
2044            canister_id: ROOT.to_string(),
2045            parent_canister_id: None,
2046            subnet_canister_id: Some(ROOT.to_string()),
2047            controller_hint: None,
2048            identity_mode: IdentityMode::Fixed,
2049            restore_group: 1,
2050            verification_class: "basic".to_string(),
2051            verification_checks: vec![VerificationCheck {
2052                kind: "status".to_string(),
2053                method: None,
2054                roles: vec!["root".to_string()],
2055            }],
2056            source_snapshot: SourceSnapshot {
2057                snapshot_id: "root-snapshot".to_string(),
2058                module_hash: None,
2059                wasm_hash: None,
2060                code_version: Some("v0.30.3".to_string()),
2061                artifact_path: "artifacts/root".to_string(),
2062                checksum_algorithm: "sha256".to_string(),
2063                checksum: None,
2064            },
2065        }
2066    }
2067
2068    // Build one durable journal with a caller-provided checksum.
2069    fn journal_with_checksum(checksum: String) -> DownloadJournal {
2070        DownloadJournal {
2071            journal_version: 1,
2072            backup_id: "backup-test".to_string(),
2073            discovery_topology_hash: Some(HASH.to_string()),
2074            pre_snapshot_topology_hash: Some(HASH.to_string()),
2075            operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
2076            artifacts: vec![ArtifactJournalEntry {
2077                canister_id: ROOT.to_string(),
2078                snapshot_id: "root-snapshot".to_string(),
2079                state: ArtifactState::Durable,
2080                temp_path: None,
2081                artifact_path: "artifacts/root".to_string(),
2082                checksum_algorithm: "sha256".to_string(),
2083                checksum: Some(checksum),
2084                updated_at: "2026-05-03T00:00:00Z".to_string(),
2085            }],
2086        }
2087    }
2088
2089    // Build one incomplete journal that still needs artifact download work.
2090    fn created_journal() -> DownloadJournal {
2091        DownloadJournal {
2092            journal_version: 1,
2093            backup_id: "backup-test".to_string(),
2094            discovery_topology_hash: Some(HASH.to_string()),
2095            pre_snapshot_topology_hash: Some(HASH.to_string()),
2096            operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
2097            artifacts: vec![ArtifactJournalEntry {
2098                canister_id: ROOT.to_string(),
2099                snapshot_id: "root-snapshot".to_string(),
2100                state: ArtifactState::Created,
2101                temp_path: None,
2102                artifact_path: "artifacts/root".to_string(),
2103                checksum_algorithm: "sha256".to_string(),
2104                checksum: None,
2105                updated_at: "2026-05-03T00:00:00Z".to_string(),
2106            }],
2107        }
2108    }
2109
2110    // Build one ready inspection report for requirement tests.
2111    fn ready_inspection_report() -> BackupInspectionReport {
2112        BackupInspectionReport {
2113            backup_id: "backup-test".to_string(),
2114            manifest_backup_id: "backup-test".to_string(),
2115            journal_backup_id: "backup-test".to_string(),
2116            backup_id_matches: true,
2117            journal_complete: true,
2118            ready_for_verify: true,
2119            manifest_members: 1,
2120            journal_artifacts: 1,
2121            matched_artifacts: 1,
2122            topology_receipt_mismatches: Vec::new(),
2123            missing_journal_artifacts: Vec::new(),
2124            unexpected_journal_artifacts: Vec::new(),
2125            path_mismatches: Vec::new(),
2126            checksum_mismatches: Vec::new(),
2127        }
2128    }
2129
2130    // Build one matching provenance report for requirement tests.
2131    fn ready_provenance_report() -> BackupProvenanceReport {
2132        BackupProvenanceReport {
2133            backup_id: "backup-test".to_string(),
2134            manifest_backup_id: "backup-test".to_string(),
2135            journal_backup_id: "backup-test".to_string(),
2136            backup_id_matches: true,
2137            manifest_version: 1,
2138            journal_version: 1,
2139            created_at: "2026-05-03T00:00:00Z".to_string(),
2140            tool_name: "canic".to_string(),
2141            tool_version: "0.30.12".to_string(),
2142            source_environment: "local".to_string(),
2143            source_root_canister: ROOT.to_string(),
2144            topology_hash_algorithm: "sha256".to_string(),
2145            topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
2146            discovery_topology_hash: HASH.to_string(),
2147            pre_snapshot_topology_hash: HASH.to_string(),
2148            accepted_topology_hash: HASH.to_string(),
2149            journal_discovery_topology_hash: Some(HASH.to_string()),
2150            journal_pre_snapshot_topology_hash: Some(HASH.to_string()),
2151            topology_receipts_match: true,
2152            topology_receipt_mismatches: Vec::new(),
2153            backup_unit_count: 1,
2154            member_count: 1,
2155            consistency_mode: "crash-consistent".to_string(),
2156            backup_units: Vec::new(),
2157            members: Vec::new(),
2158        }
2159    }
2160
2161    // Write one artifact at the layout-relative path used by test journals.
2162    fn write_artifact(root: &Path, bytes: &[u8]) -> ArtifactChecksum {
2163        let path = root.join("artifacts/root");
2164        fs::create_dir_all(path.parent().expect("artifact has parent")).expect("create artifacts");
2165        fs::write(&path, bytes).expect("write artifact");
2166        ArtifactChecksum::from_bytes(bytes)
2167    }
2168
2169    // Build a unique temporary directory.
2170    fn temp_dir(prefix: &str) -> PathBuf {
2171        let nanos = SystemTime::now()
2172            .duration_since(UNIX_EPOCH)
2173            .expect("system time after epoch")
2174            .as_nanos();
2175        std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
2176    }
2177}