Skip to main content

canic_cli/backup/
mod.rs

1use canic_backup::{
2    journal::JournalResumeReport,
3    manifest::{BackupUnitKind, ConsistencyMode, FleetBackupManifest},
4    persistence::{
5        BackupInspectionReport, BackupIntegrityReport, BackupLayout, BackupProvenanceReport,
6        PersistenceError,
7    },
8    restore::{RestoreMapping, RestorePlanError, RestorePlanner},
9};
10use serde_json::json;
11use std::{
12    ffi::OsString,
13    fs,
14    io::{self, Write},
15    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)]
132pub struct BackupPreflightReport {
133    pub status: String,
134    pub backup_id: String,
135    pub backup_dir: String,
136    pub source_environment: String,
137    pub source_root_canister: String,
138    pub topology_hash: String,
139    pub mapping_path: Option<String>,
140    pub journal_complete: bool,
141    pub inspection_status: String,
142    pub provenance_status: String,
143    pub backup_id_status: String,
144    pub topology_receipts_status: String,
145    pub topology_mismatch_count: usize,
146    pub integrity_verified: bool,
147    pub manifest_members: usize,
148    pub backup_unit_count: usize,
149    pub restore_plan_members: usize,
150    pub manifest_validation_path: String,
151    pub backup_status_path: String,
152    pub backup_inspection_path: String,
153    pub backup_provenance_path: String,
154    pub backup_integrity_path: String,
155    pub restore_plan_path: String,
156    pub preflight_summary_path: String,
157}
158
159///
160/// BackupInspectOptions
161///
162
163#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct BackupInspectOptions {
165    pub dir: PathBuf,
166    pub out: Option<PathBuf>,
167    pub require_ready: bool,
168}
169
170impl BackupInspectOptions {
171    /// Parse backup inspection options from CLI arguments.
172    pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
173    where
174        I: IntoIterator<Item = OsString>,
175    {
176        let mut dir = None;
177        let mut out = None;
178        let mut require_ready = false;
179
180        let mut args = args.into_iter();
181        while let Some(arg) = args.next() {
182            let arg = arg
183                .into_string()
184                .map_err(|_| BackupCommandError::Usage(usage()))?;
185            match arg.as_str() {
186                "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
187                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
188                "--require-ready" => require_ready = true,
189                "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
190                _ => return Err(BackupCommandError::UnknownOption(arg)),
191            }
192        }
193
194        Ok(Self {
195            dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
196            out,
197            require_ready,
198        })
199    }
200}
201
202///
203/// BackupProvenanceOptions
204///
205
206#[derive(Clone, Debug, Eq, PartialEq)]
207pub struct BackupProvenanceOptions {
208    pub dir: PathBuf,
209    pub out: Option<PathBuf>,
210    pub require_consistent: bool,
211}
212
213impl BackupProvenanceOptions {
214    /// Parse backup provenance options from CLI arguments.
215    pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
216    where
217        I: IntoIterator<Item = OsString>,
218    {
219        let mut dir = None;
220        let mut out = None;
221        let mut require_consistent = false;
222
223        let mut args = args.into_iter();
224        while let Some(arg) = args.next() {
225            let arg = arg
226                .into_string()
227                .map_err(|_| BackupCommandError::Usage(usage()))?;
228            match arg.as_str() {
229                "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
230                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
231                "--require-consistent" => require_consistent = true,
232                "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
233                _ => return Err(BackupCommandError::UnknownOption(arg)),
234            }
235        }
236
237        Ok(Self {
238            dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
239            out,
240            require_consistent,
241        })
242    }
243}
244
245///
246/// BackupVerifyOptions
247///
248
249#[derive(Clone, Debug, Eq, PartialEq)]
250pub struct BackupVerifyOptions {
251    pub dir: PathBuf,
252    pub out: Option<PathBuf>,
253}
254
255impl BackupVerifyOptions {
256    /// Parse backup verification options from CLI arguments.
257    pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
258    where
259        I: IntoIterator<Item = OsString>,
260    {
261        let mut dir = None;
262        let mut out = None;
263
264        let mut args = args.into_iter();
265        while let Some(arg) = args.next() {
266            let arg = arg
267                .into_string()
268                .map_err(|_| BackupCommandError::Usage(usage()))?;
269            match arg.as_str() {
270                "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
271                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
272                "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
273                _ => return Err(BackupCommandError::UnknownOption(arg)),
274            }
275        }
276
277        Ok(Self {
278            dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
279            out,
280        })
281    }
282}
283
284///
285/// BackupStatusOptions
286///
287
288#[derive(Clone, Debug, Eq, PartialEq)]
289pub struct BackupStatusOptions {
290    pub dir: PathBuf,
291    pub out: Option<PathBuf>,
292    pub require_complete: bool,
293}
294
295impl BackupStatusOptions {
296    /// Parse backup status options from CLI arguments.
297    pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
298    where
299        I: IntoIterator<Item = OsString>,
300    {
301        let mut dir = None;
302        let mut out = None;
303        let mut require_complete = false;
304
305        let mut args = args.into_iter();
306        while let Some(arg) = args.next() {
307            let arg = arg
308                .into_string()
309                .map_err(|_| BackupCommandError::Usage(usage()))?;
310            match arg.as_str() {
311                "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
312                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
313                "--require-complete" => require_complete = true,
314                "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
315                _ => return Err(BackupCommandError::UnknownOption(arg)),
316            }
317        }
318
319        Ok(Self {
320            dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
321            out,
322            require_complete,
323        })
324    }
325}
326
327/// Run a backup subcommand.
328pub fn run<I>(args: I) -> Result<(), BackupCommandError>
329where
330    I: IntoIterator<Item = OsString>,
331{
332    let mut args = args.into_iter();
333    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
334        return Err(BackupCommandError::Usage(usage()));
335    };
336
337    match command.as_str() {
338        "preflight" => {
339            let options = BackupPreflightOptions::parse(args)?;
340            backup_preflight(&options)?;
341            Ok(())
342        }
343        "inspect" => {
344            let options = BackupInspectOptions::parse(args)?;
345            let report = inspect_backup(&options)?;
346            write_inspect_report(&options, &report)?;
347            enforce_inspection_requirements(&options, &report)?;
348            Ok(())
349        }
350        "provenance" => {
351            let options = BackupProvenanceOptions::parse(args)?;
352            let report = backup_provenance(&options)?;
353            write_provenance_report(&options, &report)?;
354            enforce_provenance_requirements(&options, &report)?;
355            Ok(())
356        }
357        "status" => {
358            let options = BackupStatusOptions::parse(args)?;
359            let report = backup_status(&options)?;
360            write_status_report(&options, &report)?;
361            enforce_status_requirements(&options, &report)?;
362            Ok(())
363        }
364        "verify" => {
365            let options = BackupVerifyOptions::parse(args)?;
366            let report = verify_backup(&options)?;
367            write_report(&options, &report)?;
368            Ok(())
369        }
370        "help" | "--help" | "-h" => Err(BackupCommandError::Usage(usage())),
371        _ => Err(BackupCommandError::UnknownOption(command)),
372    }
373}
374
375/// Run all no-mutation backup checks and write standard preflight artifacts.
376pub fn backup_preflight(
377    options: &BackupPreflightOptions,
378) -> Result<BackupPreflightReport, BackupCommandError> {
379    fs::create_dir_all(&options.out_dir)?;
380
381    let layout = BackupLayout::new(options.dir.clone());
382    let manifest = layout.read_manifest()?;
383    let status = layout.read_journal()?.resume_report();
384    ensure_complete_status(&status)?;
385    let inspection = layout.inspect()?;
386    let provenance = layout.provenance()?;
387    let integrity = layout.verify_integrity()?;
388    let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
389    let restore_plan = RestorePlanner::plan(&manifest, mapping.as_ref())?;
390
391    let manifest_validation_path = options.out_dir.join("manifest-validation.json");
392    let backup_status_path = options.out_dir.join("backup-status.json");
393    let backup_inspection_path = options.out_dir.join("backup-inspection.json");
394    let backup_provenance_path = options.out_dir.join("backup-provenance.json");
395    let backup_integrity_path = options.out_dir.join("backup-integrity.json");
396    let restore_plan_path = options.out_dir.join("restore-plan.json");
397    let preflight_summary_path = options.out_dir.join("preflight-summary.json");
398
399    write_json_value_file(
400        &manifest_validation_path,
401        &manifest_validation_summary(&manifest),
402    )?;
403    fs::write(&backup_status_path, serde_json::to_vec_pretty(&status)?)?;
404    fs::write(
405        &backup_inspection_path,
406        serde_json::to_vec_pretty(&inspection)?,
407    )?;
408    fs::write(
409        &backup_provenance_path,
410        serde_json::to_vec_pretty(&provenance)?,
411    )?;
412    fs::write(
413        &backup_integrity_path,
414        serde_json::to_vec_pretty(&integrity)?,
415    )?;
416    fs::write(
417        &restore_plan_path,
418        serde_json::to_vec_pretty(&restore_plan)?,
419    )?;
420
421    let report = BackupPreflightReport {
422        status: "ready".to_string(),
423        backup_id: manifest.backup_id.clone(),
424        backup_dir: options.dir.display().to_string(),
425        source_environment: manifest.source.environment.clone(),
426        source_root_canister: manifest.source.root_canister.clone(),
427        topology_hash: manifest.fleet.topology_hash.clone(),
428        mapping_path: options
429            .mapping
430            .as_ref()
431            .map(|path| path.display().to_string()),
432        journal_complete: status.is_complete,
433        inspection_status: readiness_status(inspection.ready_for_verify).to_string(),
434        provenance_status: consistency_status(
435            provenance.backup_id_matches && provenance.topology_receipts_match,
436        )
437        .to_string(),
438        backup_id_status: match_status(provenance.backup_id_matches).to_string(),
439        topology_receipts_status: match_status(provenance.topology_receipts_match).to_string(),
440        topology_mismatch_count: provenance.topology_receipt_mismatches.len(),
441        integrity_verified: integrity.verified,
442        manifest_members: manifest.fleet.members.len(),
443        backup_unit_count: provenance.backup_unit_count,
444        restore_plan_members: restore_plan.member_count,
445        manifest_validation_path: manifest_validation_path.display().to_string(),
446        backup_status_path: backup_status_path.display().to_string(),
447        backup_inspection_path: backup_inspection_path.display().to_string(),
448        backup_provenance_path: backup_provenance_path.display().to_string(),
449        backup_integrity_path: backup_integrity_path.display().to_string(),
450        restore_plan_path: restore_plan_path.display().to_string(),
451        preflight_summary_path: preflight_summary_path.display().to_string(),
452    };
453
454    write_json_value_file(&preflight_summary_path, &preflight_summary_value(&report))?;
455    Ok(report)
456}
457
458/// Inspect manifest and journal agreement without reading artifact bytes.
459pub fn inspect_backup(
460    options: &BackupInspectOptions,
461) -> Result<BackupInspectionReport, BackupCommandError> {
462    let layout = BackupLayout::new(options.dir.clone());
463    layout.inspect().map_err(BackupCommandError::from)
464}
465
466/// Report manifest and journal provenance without reading artifact bytes.
467pub fn backup_provenance(
468    options: &BackupProvenanceOptions,
469) -> Result<BackupProvenanceReport, BackupCommandError> {
470    let layout = BackupLayout::new(options.dir.clone());
471    layout.provenance().map_err(BackupCommandError::from)
472}
473
474// Ensure provenance is internally consistent when requested by scripts.
475fn enforce_provenance_requirements(
476    options: &BackupProvenanceOptions,
477    report: &BackupProvenanceReport,
478) -> Result<(), BackupCommandError> {
479    if !options.require_consistent || (report.backup_id_matches && report.topology_receipts_match) {
480        return Ok(());
481    }
482
483    Err(BackupCommandError::ProvenanceNotConsistent {
484        backup_id: report.backup_id.clone(),
485        backup_id_matches: report.backup_id_matches,
486        topology_receipts_match: report.topology_receipts_match,
487        topology_mismatches: report.topology_receipt_mismatches.len(),
488    })
489}
490
491// Ensure an inspection report is ready for full verification when requested.
492fn enforce_inspection_requirements(
493    options: &BackupInspectOptions,
494    report: &BackupInspectionReport,
495) -> Result<(), BackupCommandError> {
496    if !options.require_ready || report.ready_for_verify {
497        return Ok(());
498    }
499
500    Err(BackupCommandError::InspectionNotReady {
501        backup_id: report.backup_id.clone(),
502        backup_id_matches: report.backup_id_matches,
503        topology_receipts_match: report.topology_receipt_mismatches.is_empty(),
504        journal_complete: report.journal_complete,
505        topology_mismatches: report.topology_receipt_mismatches.len(),
506        missing_artifacts: report.missing_journal_artifacts.len(),
507        unexpected_artifacts: report.unexpected_journal_artifacts.len(),
508        path_mismatches: report.path_mismatches.len(),
509        checksum_mismatches: report.checksum_mismatches.len(),
510    })
511}
512
513/// Summarize a backup journal's resumable state.
514pub fn backup_status(
515    options: &BackupStatusOptions,
516) -> Result<JournalResumeReport, BackupCommandError> {
517    let layout = BackupLayout::new(options.dir.clone());
518    let journal = layout.read_journal()?;
519    Ok(journal.resume_report())
520}
521
522// Ensure a journal status report has no remaining resume work.
523fn ensure_complete_status(report: &JournalResumeReport) -> Result<(), BackupCommandError> {
524    if report.is_complete {
525        return Ok(());
526    }
527
528    Err(BackupCommandError::IncompleteJournal {
529        backup_id: report.backup_id.clone(),
530        total_artifacts: report.total_artifacts,
531        pending_artifacts: report.pending_artifacts,
532    })
533}
534
535// Enforce caller-requested status requirements after the JSON report is written.
536fn enforce_status_requirements(
537    options: &BackupStatusOptions,
538    report: &JournalResumeReport,
539) -> Result<(), BackupCommandError> {
540    if !options.require_complete {
541        return Ok(());
542    }
543
544    ensure_complete_status(report)
545}
546
547/// Verify a backup directory's manifest, journal, and durable artifacts.
548pub fn verify_backup(
549    options: &BackupVerifyOptions,
550) -> Result<BackupIntegrityReport, BackupCommandError> {
551    let layout = BackupLayout::new(options.dir.clone());
552    layout.verify_integrity().map_err(BackupCommandError::from)
553}
554
555// Write the journal status report to stdout or a requested output file.
556fn write_status_report(
557    options: &BackupStatusOptions,
558    report: &JournalResumeReport,
559) -> Result<(), BackupCommandError> {
560    if let Some(path) = &options.out {
561        let data = serde_json::to_vec_pretty(report)?;
562        fs::write(path, data)?;
563        return Ok(());
564    }
565
566    let stdout = io::stdout();
567    let mut handle = stdout.lock();
568    serde_json::to_writer_pretty(&mut handle, report)?;
569    writeln!(handle)?;
570    Ok(())
571}
572
573// Write the inspection report to stdout or a requested output file.
574fn write_inspect_report(
575    options: &BackupInspectOptions,
576    report: &BackupInspectionReport,
577) -> Result<(), BackupCommandError> {
578    if let Some(path) = &options.out {
579        let data = serde_json::to_vec_pretty(report)?;
580        fs::write(path, data)?;
581        return Ok(());
582    }
583
584    let stdout = io::stdout();
585    let mut handle = stdout.lock();
586    serde_json::to_writer_pretty(&mut handle, report)?;
587    writeln!(handle)?;
588    Ok(())
589}
590
591// Write the provenance report to stdout or a requested output file.
592fn write_provenance_report(
593    options: &BackupProvenanceOptions,
594    report: &BackupProvenanceReport,
595) -> Result<(), BackupCommandError> {
596    if let Some(path) = &options.out {
597        let data = serde_json::to_vec_pretty(report)?;
598        fs::write(path, data)?;
599        return Ok(());
600    }
601
602    let stdout = io::stdout();
603    let mut handle = stdout.lock();
604    serde_json::to_writer_pretty(&mut handle, report)?;
605    writeln!(handle)?;
606    Ok(())
607}
608
609// Write the integrity report to stdout or a requested output file.
610fn write_report(
611    options: &BackupVerifyOptions,
612    report: &BackupIntegrityReport,
613) -> Result<(), BackupCommandError> {
614    if let Some(path) = &options.out {
615        let data = serde_json::to_vec_pretty(report)?;
616        fs::write(path, data)?;
617        return Ok(());
618    }
619
620    let stdout = io::stdout();
621    let mut handle = stdout.lock();
622    serde_json::to_writer_pretty(&mut handle, report)?;
623    writeln!(handle)?;
624    Ok(())
625}
626
627// Write one pretty JSON value artifact, creating its parent directory when needed.
628fn write_json_value_file(
629    path: &PathBuf,
630    value: &serde_json::Value,
631) -> Result<(), BackupCommandError> {
632    if let Some(parent) = path.parent() {
633        fs::create_dir_all(parent)?;
634    }
635
636    let data = serde_json::to_vec_pretty(value)?;
637    fs::write(path, data)?;
638    Ok(())
639}
640
641// Build the compact preflight summary emitted after all checks pass.
642fn preflight_summary_value(report: &BackupPreflightReport) -> serde_json::Value {
643    json!({
644        "status": report.status,
645        "backup_id": report.backup_id,
646        "backup_dir": report.backup_dir,
647        "source_environment": report.source_environment,
648        "source_root_canister": report.source_root_canister,
649        "topology_hash": report.topology_hash,
650        "mapping_path": report.mapping_path,
651        "journal_complete": report.journal_complete,
652        "inspection_status": report.inspection_status,
653        "provenance_status": report.provenance_status,
654        "backup_id_status": report.backup_id_status,
655        "topology_receipts_status": report.topology_receipts_status,
656        "topology_mismatch_count": report.topology_mismatch_count,
657        "integrity_verified": report.integrity_verified,
658        "manifest_members": report.manifest_members,
659        "backup_unit_count": report.backup_unit_count,
660        "restore_plan_members": report.restore_plan_members,
661        "manifest_validation_path": report.manifest_validation_path,
662        "backup_status_path": report.backup_status_path,
663        "backup_inspection_path": report.backup_inspection_path,
664        "backup_provenance_path": report.backup_provenance_path,
665        "backup_integrity_path": report.backup_integrity_path,
666        "restore_plan_path": report.restore_plan_path,
667        "preflight_summary_path": report.preflight_summary_path,
668    })
669}
670
671// Build the same compact validation summary emitted by manifest validation.
672fn manifest_validation_summary(manifest: &FleetBackupManifest) -> serde_json::Value {
673    json!({
674        "status": "valid",
675        "backup_id": manifest.backup_id,
676        "members": manifest.fleet.members.len(),
677        "backup_unit_count": manifest.consistency.backup_units.len(),
678        "consistency_mode": consistency_mode_name(&manifest.consistency.mode),
679        "topology_hash": manifest.fleet.topology_hash,
680        "topology_hash_algorithm": manifest.fleet.topology_hash_algorithm,
681        "topology_hash_input": manifest.fleet.topology_hash_input,
682        "topology_validation_status": "validated",
683        "backup_unit_kinds": backup_unit_kind_counts(manifest),
684        "backup_units": manifest
685            .consistency
686            .backup_units
687            .iter()
688            .map(|unit| json!({
689                "unit_id": unit.unit_id,
690                "kind": backup_unit_kind_name(&unit.kind),
691                "role_count": unit.roles.len(),
692                "dependency_count": unit.dependency_closure.len(),
693                "topology_validation": unit.topology_validation,
694            }))
695            .collect::<Vec<_>>(),
696    })
697}
698
699// Count backup units by stable serialized kind name.
700fn backup_unit_kind_counts(manifest: &FleetBackupManifest) -> serde_json::Value {
701    let mut whole_fleet = 0;
702    let mut control_plane_subset = 0;
703    let mut subtree_rooted = 0;
704    let mut flat = 0;
705    for unit in &manifest.consistency.backup_units {
706        match &unit.kind {
707            BackupUnitKind::WholeFleet => whole_fleet += 1,
708            BackupUnitKind::ControlPlaneSubset => control_plane_subset += 1,
709            BackupUnitKind::SubtreeRooted => subtree_rooted += 1,
710            BackupUnitKind::Flat => flat += 1,
711        }
712    }
713
714    json!({
715        "whole_fleet": whole_fleet,
716        "control_plane_subset": control_plane_subset,
717        "subtree_rooted": subtree_rooted,
718        "flat": flat,
719    })
720}
721
722// Return the stable serialized name for a consistency mode.
723const fn consistency_mode_name(mode: &ConsistencyMode) -> &'static str {
724    match mode {
725        ConsistencyMode::CrashConsistent => "crash-consistent",
726        ConsistencyMode::QuiescedUnit => "quiesced-unit",
727    }
728}
729
730// Return the stable serialized name for a backup unit kind.
731const fn backup_unit_kind_name(kind: &BackupUnitKind) -> &'static str {
732    match kind {
733        BackupUnitKind::WholeFleet => "whole-fleet",
734        BackupUnitKind::ControlPlaneSubset => "control-plane-subset",
735        BackupUnitKind::SubtreeRooted => "subtree-rooted",
736        BackupUnitKind::Flat => "flat",
737    }
738}
739
740// Return the stable summary status for inspection readiness.
741const fn readiness_status(ready: bool) -> &'static str {
742    if ready { "ready" } else { "not-ready" }
743}
744
745// Return the stable summary status for provenance consistency.
746const fn consistency_status(consistent: bool) -> &'static str {
747    if consistent {
748        "consistent"
749    } else {
750        "inconsistent"
751    }
752}
753
754// Return the stable summary status for equality checks.
755const fn match_status(matches: bool) -> &'static str {
756    if matches { "matched" } else { "mismatched" }
757}
758
759// Read and decode an optional source-to-target restore mapping from disk.
760fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, BackupCommandError> {
761    let data = fs::read_to_string(path)?;
762    serde_json::from_str(&data).map_err(BackupCommandError::from)
763}
764
765// Read the next required option value.
766fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, BackupCommandError>
767where
768    I: Iterator<Item = OsString>,
769{
770    args.next()
771        .and_then(|value| value.into_string().ok())
772        .ok_or(BackupCommandError::MissingValue(option))
773}
774
775// Return backup command usage text.
776const fn usage() -> &'static str {
777    "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>]"
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783    use canic_backup::{
784        artifacts::ArtifactChecksum,
785        journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
786        manifest::{
787            BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetBackupManifest,
788            FleetMember, FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
789            VerificationCheck, VerificationPlan,
790        },
791    };
792    use std::{
793        fs,
794        path::Path,
795        time::{SystemTime, UNIX_EPOCH},
796    };
797
798    const ROOT: &str = "aaaaa-aa";
799    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
800
801    // Ensure backup preflight options parse the intended command shape.
802    #[test]
803    fn parses_backup_preflight_options() {
804        let options = BackupPreflightOptions::parse([
805            OsString::from("--dir"),
806            OsString::from("backups/run"),
807            OsString::from("--out-dir"),
808            OsString::from("reports/run"),
809            OsString::from("--mapping"),
810            OsString::from("mapping.json"),
811        ])
812        .expect("parse options");
813
814        assert_eq!(options.dir, PathBuf::from("backups/run"));
815        assert_eq!(options.out_dir, PathBuf::from("reports/run"));
816        assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
817    }
818
819    // Ensure preflight writes the standard no-mutation report bundle.
820    #[test]
821    fn backup_preflight_writes_standard_reports() {
822        let root = temp_dir("canic-cli-backup-preflight");
823        let out_dir = root.join("reports");
824        let backup_dir = root.join("backup");
825        let layout = BackupLayout::new(backup_dir.clone());
826        let checksum = write_artifact(&backup_dir, b"root artifact");
827
828        layout
829            .write_manifest(&valid_manifest())
830            .expect("write manifest");
831        layout
832            .write_journal(&journal_with_checksum(checksum.hash))
833            .expect("write journal");
834
835        let options = BackupPreflightOptions {
836            dir: backup_dir,
837            out_dir: out_dir.clone(),
838            mapping: None,
839        };
840        let report = backup_preflight(&options).expect("run preflight");
841
842        assert_eq!(report.status, "ready");
843        assert_eq!(report.backup_id, "backup-test");
844        assert_eq!(report.source_environment, "local");
845        assert_eq!(report.source_root_canister, ROOT);
846        assert_eq!(report.topology_hash, HASH);
847        assert_eq!(report.mapping_path, None);
848        assert!(report.journal_complete);
849        assert_eq!(report.inspection_status, "ready");
850        assert_eq!(report.provenance_status, "consistent");
851        assert_eq!(report.backup_id_status, "matched");
852        assert_eq!(report.topology_receipts_status, "matched");
853        assert_eq!(report.topology_mismatch_count, 0);
854        assert!(report.integrity_verified);
855        assert_eq!(report.manifest_members, 1);
856        assert_eq!(report.backup_unit_count, 1);
857        assert_eq!(report.restore_plan_members, 1);
858        assert!(out_dir.join("manifest-validation.json").exists());
859        assert!(out_dir.join("backup-status.json").exists());
860        assert!(out_dir.join("backup-inspection.json").exists());
861        assert!(out_dir.join("backup-provenance.json").exists());
862        assert!(out_dir.join("backup-integrity.json").exists());
863        assert!(out_dir.join("restore-plan.json").exists());
864        assert!(out_dir.join("preflight-summary.json").exists());
865
866        let summary: serde_json::Value = serde_json::from_slice(
867            &fs::read(out_dir.join("preflight-summary.json")).expect("read summary"),
868        )
869        .expect("decode summary");
870        let manifest_validation: serde_json::Value = serde_json::from_slice(
871            &fs::read(out_dir.join("manifest-validation.json")).expect("read manifest summary"),
872        )
873        .expect("decode manifest summary");
874
875        fs::remove_dir_all(root).expect("remove temp root");
876        assert_eq!(summary["status"], report.status);
877        assert_eq!(summary["backup_id"], report.backup_id);
878        assert_eq!(summary["source_environment"], report.source_environment);
879        assert_eq!(summary["source_root_canister"], report.source_root_canister);
880        assert_eq!(summary["topology_hash"], report.topology_hash);
881        assert_eq!(summary["journal_complete"], report.journal_complete);
882        assert_eq!(summary["inspection_status"], report.inspection_status);
883        assert_eq!(summary["provenance_status"], report.provenance_status);
884        assert_eq!(summary["backup_id_status"], report.backup_id_status);
885        assert_eq!(
886            summary["topology_receipts_status"],
887            report.topology_receipts_status
888        );
889        assert_eq!(
890            summary["topology_mismatch_count"],
891            report.topology_mismatch_count
892        );
893        assert_eq!(summary["integrity_verified"], report.integrity_verified);
894        assert_eq!(summary["manifest_members"], report.manifest_members);
895        assert_eq!(summary["backup_unit_count"], report.backup_unit_count);
896        assert_eq!(summary["restore_plan_members"], report.restore_plan_members);
897        assert_eq!(
898            summary["backup_inspection_path"],
899            report.backup_inspection_path
900        );
901        assert_eq!(
902            summary["backup_provenance_path"],
903            report.backup_provenance_path
904        );
905        assert_eq!(manifest_validation["backup_unit_count"], 1);
906        assert_eq!(manifest_validation["consistency_mode"], "crash-consistent");
907        assert_eq!(
908            manifest_validation["topology_validation_status"],
909            "validated"
910        );
911        assert_eq!(
912            manifest_validation["backup_unit_kinds"]["subtree_rooted"],
913            1
914        );
915        assert_eq!(
916            manifest_validation["backup_units"][0]["kind"],
917            "subtree-rooted"
918        );
919    }
920
921    // Ensure preflight stops on incomplete journals before claiming readiness.
922    #[test]
923    fn backup_preflight_rejects_incomplete_journal() {
924        let root = temp_dir("canic-cli-backup-preflight-incomplete");
925        let out_dir = root.join("reports");
926        let backup_dir = root.join("backup");
927        let layout = BackupLayout::new(backup_dir.clone());
928
929        layout
930            .write_manifest(&valid_manifest())
931            .expect("write manifest");
932        layout
933            .write_journal(&created_journal())
934            .expect("write journal");
935
936        let options = BackupPreflightOptions {
937            dir: backup_dir,
938            out_dir,
939            mapping: None,
940        };
941
942        let err = backup_preflight(&options).expect_err("incomplete journal should fail");
943
944        fs::remove_dir_all(root).expect("remove temp root");
945        assert!(matches!(
946            err,
947            BackupCommandError::IncompleteJournal {
948                pending_artifacts: 1,
949                total_artifacts: 1,
950                ..
951            }
952        ));
953    }
954
955    // Ensure backup verification options parse the intended command shape.
956    #[test]
957    fn parses_backup_verify_options() {
958        let options = BackupVerifyOptions::parse([
959            OsString::from("--dir"),
960            OsString::from("backups/run"),
961            OsString::from("--out"),
962            OsString::from("report.json"),
963        ])
964        .expect("parse options");
965
966        assert_eq!(options.dir, PathBuf::from("backups/run"));
967        assert_eq!(options.out, Some(PathBuf::from("report.json")));
968    }
969
970    // Ensure backup inspection options parse the intended command shape.
971    #[test]
972    fn parses_backup_inspect_options() {
973        let options = BackupInspectOptions::parse([
974            OsString::from("--dir"),
975            OsString::from("backups/run"),
976            OsString::from("--out"),
977            OsString::from("inspect.json"),
978            OsString::from("--require-ready"),
979        ])
980        .expect("parse options");
981
982        assert_eq!(options.dir, PathBuf::from("backups/run"));
983        assert_eq!(options.out, Some(PathBuf::from("inspect.json")));
984        assert!(options.require_ready);
985    }
986
987    // Ensure backup provenance options parse the intended command shape.
988    #[test]
989    fn parses_backup_provenance_options() {
990        let options = BackupProvenanceOptions::parse([
991            OsString::from("--dir"),
992            OsString::from("backups/run"),
993            OsString::from("--out"),
994            OsString::from("provenance.json"),
995            OsString::from("--require-consistent"),
996        ])
997        .expect("parse options");
998
999        assert_eq!(options.dir, PathBuf::from("backups/run"));
1000        assert_eq!(options.out, Some(PathBuf::from("provenance.json")));
1001        assert!(options.require_consistent);
1002    }
1003
1004    // Ensure backup status options parse the intended command shape.
1005    #[test]
1006    fn parses_backup_status_options() {
1007        let options = BackupStatusOptions::parse([
1008            OsString::from("--dir"),
1009            OsString::from("backups/run"),
1010            OsString::from("--out"),
1011            OsString::from("status.json"),
1012            OsString::from("--require-complete"),
1013        ])
1014        .expect("parse options");
1015
1016        assert_eq!(options.dir, PathBuf::from("backups/run"));
1017        assert_eq!(options.out, Some(PathBuf::from("status.json")));
1018        assert!(options.require_complete);
1019    }
1020
1021    // Ensure backup status reads the journal and reports resume actions.
1022    #[test]
1023    fn backup_status_reads_journal_resume_report() {
1024        let root = temp_dir("canic-cli-backup-status");
1025        let layout = BackupLayout::new(root.clone());
1026        layout
1027            .write_journal(&journal_with_checksum(HASH.to_string()))
1028            .expect("write journal");
1029
1030        let options = BackupStatusOptions {
1031            dir: root.clone(),
1032            out: None,
1033            require_complete: false,
1034        };
1035        let report = backup_status(&options).expect("read backup status");
1036
1037        fs::remove_dir_all(root).expect("remove temp root");
1038        assert_eq!(report.backup_id, "backup-test");
1039        assert_eq!(report.total_artifacts, 1);
1040        assert!(report.is_complete);
1041        assert_eq!(report.pending_artifacts, 0);
1042        assert_eq!(report.counts.skip, 1);
1043    }
1044
1045    // Ensure backup inspection reports manifest and journal agreement.
1046    #[test]
1047    fn inspect_backup_reads_layout_metadata() {
1048        let root = temp_dir("canic-cli-backup-inspect");
1049        let layout = BackupLayout::new(root.clone());
1050
1051        layout
1052            .write_manifest(&valid_manifest())
1053            .expect("write manifest");
1054        layout
1055            .write_journal(&journal_with_checksum(HASH.to_string()))
1056            .expect("write journal");
1057
1058        let options = BackupInspectOptions {
1059            dir: root.clone(),
1060            out: None,
1061            require_ready: false,
1062        };
1063        let report = inspect_backup(&options).expect("inspect backup");
1064
1065        fs::remove_dir_all(root).expect("remove temp root");
1066        assert_eq!(report.backup_id, "backup-test");
1067        assert!(report.backup_id_matches);
1068        assert!(report.journal_complete);
1069        assert!(report.ready_for_verify);
1070        assert!(report.topology_receipt_mismatches.is_empty());
1071        assert_eq!(report.matched_artifacts, 1);
1072    }
1073
1074    // Ensure backup provenance reports manifest and journal audit metadata.
1075    #[test]
1076    fn backup_provenance_reads_layout_metadata() {
1077        let root = temp_dir("canic-cli-backup-provenance");
1078        let layout = BackupLayout::new(root.clone());
1079
1080        layout
1081            .write_manifest(&valid_manifest())
1082            .expect("write manifest");
1083        layout
1084            .write_journal(&journal_with_checksum(HASH.to_string()))
1085            .expect("write journal");
1086
1087        let options = BackupProvenanceOptions {
1088            dir: root.clone(),
1089            out: None,
1090            require_consistent: false,
1091        };
1092        let report = backup_provenance(&options).expect("read provenance");
1093
1094        fs::remove_dir_all(root).expect("remove temp root");
1095        assert_eq!(report.backup_id, "backup-test");
1096        assert!(report.backup_id_matches);
1097        assert_eq!(report.source_environment, "local");
1098        assert_eq!(report.discovery_topology_hash, HASH);
1099        assert!(report.topology_receipts_match);
1100        assert!(report.topology_receipt_mismatches.is_empty());
1101        assert_eq!(report.backup_unit_count, 1);
1102        assert_eq!(report.member_count, 1);
1103        assert_eq!(report.backup_units[0].kind, "subtree-rooted");
1104        assert_eq!(report.members[0].canister_id, ROOT);
1105        assert_eq!(report.members[0].snapshot_id, "root-snapshot");
1106        assert_eq!(report.members[0].journal_state, Some("Durable".to_string()));
1107    }
1108
1109    // Ensure require-consistent accepts matching provenance reports.
1110    #[test]
1111    fn require_consistent_accepts_matching_provenance() {
1112        let options = BackupProvenanceOptions {
1113            dir: PathBuf::from("unused"),
1114            out: None,
1115            require_consistent: true,
1116        };
1117        let report = ready_provenance_report();
1118
1119        enforce_provenance_requirements(&options, &report)
1120            .expect("matching provenance should pass");
1121    }
1122
1123    // Ensure require-consistent rejects backup ID or topology receipt drift.
1124    #[test]
1125    fn require_consistent_rejects_provenance_drift() {
1126        let options = BackupProvenanceOptions {
1127            dir: PathBuf::from("unused"),
1128            out: None,
1129            require_consistent: true,
1130        };
1131        let mut report = ready_provenance_report();
1132        report.backup_id_matches = false;
1133        report.journal_backup_id = "other-backup".to_string();
1134        report.topology_receipts_match = false;
1135        report.topology_receipt_mismatches.push(
1136            canic_backup::persistence::TopologyReceiptMismatch {
1137                field: "pre_snapshot_topology_hash".to_string(),
1138                manifest: HASH.to_string(),
1139                journal: None,
1140            },
1141        );
1142
1143        let err = enforce_provenance_requirements(&options, &report)
1144            .expect_err("provenance drift should fail");
1145
1146        assert!(matches!(
1147            err,
1148            BackupCommandError::ProvenanceNotConsistent {
1149                backup_id_matches: false,
1150                topology_receipts_match: false,
1151                topology_mismatches: 1,
1152                ..
1153            }
1154        ));
1155    }
1156
1157    // Ensure require-ready accepts inspection reports ready for verification.
1158    #[test]
1159    fn require_ready_accepts_ready_inspection() {
1160        let options = BackupInspectOptions {
1161            dir: PathBuf::from("unused"),
1162            out: None,
1163            require_ready: true,
1164        };
1165        let report = ready_inspection_report();
1166
1167        enforce_inspection_requirements(&options, &report).expect("ready inspection should pass");
1168    }
1169
1170    // Ensure require-ready rejects inspection reports with metadata drift.
1171    #[test]
1172    fn require_ready_rejects_unready_inspection() {
1173        let options = BackupInspectOptions {
1174            dir: PathBuf::from("unused"),
1175            out: None,
1176            require_ready: true,
1177        };
1178        let mut report = ready_inspection_report();
1179        report.ready_for_verify = false;
1180        report
1181            .path_mismatches
1182            .push(canic_backup::persistence::ArtifactPathMismatch {
1183                canister_id: ROOT.to_string(),
1184                snapshot_id: "root-snapshot".to_string(),
1185                manifest: "artifacts/root".to_string(),
1186                journal: "artifacts/other-root".to_string(),
1187            });
1188
1189        let err = enforce_inspection_requirements(&options, &report)
1190            .expect_err("unready inspection should fail");
1191
1192        assert!(matches!(
1193            err,
1194            BackupCommandError::InspectionNotReady {
1195                path_mismatches: 1,
1196                ..
1197            }
1198        ));
1199    }
1200
1201    // Ensure require-ready rejects topology receipt drift.
1202    #[test]
1203    fn require_ready_rejects_topology_receipt_drift() {
1204        let options = BackupInspectOptions {
1205            dir: PathBuf::from("unused"),
1206            out: None,
1207            require_ready: true,
1208        };
1209        let mut report = ready_inspection_report();
1210        report.ready_for_verify = false;
1211        report.topology_receipt_mismatches.push(
1212            canic_backup::persistence::TopologyReceiptMismatch {
1213                field: "discovery_topology_hash".to_string(),
1214                manifest: HASH.to_string(),
1215                journal: None,
1216            },
1217        );
1218
1219        let err = enforce_inspection_requirements(&options, &report)
1220            .expect_err("topology receipt drift should fail");
1221
1222        assert!(matches!(
1223            err,
1224            BackupCommandError::InspectionNotReady {
1225                topology_receipts_match: false,
1226                topology_mismatches: 1,
1227                ..
1228            }
1229        ));
1230    }
1231
1232    // Ensure require-complete accepts already durable backup journals.
1233    #[test]
1234    fn require_complete_accepts_complete_status() {
1235        let options = BackupStatusOptions {
1236            dir: PathBuf::from("unused"),
1237            out: None,
1238            require_complete: true,
1239        };
1240        let report = journal_with_checksum(HASH.to_string()).resume_report();
1241
1242        enforce_status_requirements(&options, &report).expect("complete status should pass");
1243    }
1244
1245    // Ensure require-complete rejects journals that still need resume work.
1246    #[test]
1247    fn require_complete_rejects_incomplete_status() {
1248        let options = BackupStatusOptions {
1249            dir: PathBuf::from("unused"),
1250            out: None,
1251            require_complete: true,
1252        };
1253        let report = created_journal().resume_report();
1254
1255        let err = enforce_status_requirements(&options, &report)
1256            .expect_err("incomplete status should fail");
1257
1258        assert!(matches!(
1259            err,
1260            BackupCommandError::IncompleteJournal {
1261                pending_artifacts: 1,
1262                total_artifacts: 1,
1263                ..
1264            }
1265        ));
1266    }
1267
1268    // Ensure the CLI verification path reads a layout and returns an integrity report.
1269    #[test]
1270    fn verify_backup_reads_layout_and_artifacts() {
1271        let root = temp_dir("canic-cli-backup-verify");
1272        let layout = BackupLayout::new(root.clone());
1273        let checksum = write_artifact(&root, b"root artifact");
1274
1275        layout
1276            .write_manifest(&valid_manifest())
1277            .expect("write manifest");
1278        layout
1279            .write_journal(&journal_with_checksum(checksum.hash.clone()))
1280            .expect("write journal");
1281
1282        let options = BackupVerifyOptions {
1283            dir: root.clone(),
1284            out: None,
1285        };
1286        let report = verify_backup(&options).expect("verify backup");
1287
1288        fs::remove_dir_all(root).expect("remove temp root");
1289        assert_eq!(report.backup_id, "backup-test");
1290        assert!(report.verified);
1291        assert_eq!(report.durable_artifacts, 1);
1292        assert_eq!(report.artifacts[0].checksum, checksum.hash);
1293    }
1294
1295    // Build one valid manifest for CLI verification tests.
1296    fn valid_manifest() -> FleetBackupManifest {
1297        FleetBackupManifest {
1298            manifest_version: 1,
1299            backup_id: "backup-test".to_string(),
1300            created_at: "2026-05-03T00:00:00Z".to_string(),
1301            tool: ToolMetadata {
1302                name: "canic".to_string(),
1303                version: "0.30.3".to_string(),
1304            },
1305            source: SourceMetadata {
1306                environment: "local".to_string(),
1307                root_canister: ROOT.to_string(),
1308            },
1309            consistency: ConsistencySection {
1310                mode: ConsistencyMode::CrashConsistent,
1311                backup_units: vec![BackupUnit {
1312                    unit_id: "fleet".to_string(),
1313                    kind: BackupUnitKind::SubtreeRooted,
1314                    roles: vec!["root".to_string()],
1315                    consistency_reason: None,
1316                    dependency_closure: Vec::new(),
1317                    topology_validation: "subtree-closed".to_string(),
1318                    quiescence_strategy: None,
1319                }],
1320            },
1321            fleet: FleetSection {
1322                topology_hash_algorithm: "sha256".to_string(),
1323                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1324                discovery_topology_hash: HASH.to_string(),
1325                pre_snapshot_topology_hash: HASH.to_string(),
1326                topology_hash: HASH.to_string(),
1327                members: vec![fleet_member()],
1328            },
1329            verification: VerificationPlan::default(),
1330        }
1331    }
1332
1333    // Build one valid manifest member.
1334    fn fleet_member() -> FleetMember {
1335        FleetMember {
1336            role: "root".to_string(),
1337            canister_id: ROOT.to_string(),
1338            parent_canister_id: None,
1339            subnet_canister_id: Some(ROOT.to_string()),
1340            controller_hint: None,
1341            identity_mode: IdentityMode::Fixed,
1342            restore_group: 1,
1343            verification_class: "basic".to_string(),
1344            verification_checks: vec![VerificationCheck {
1345                kind: "status".to_string(),
1346                method: None,
1347                roles: vec!["root".to_string()],
1348            }],
1349            source_snapshot: SourceSnapshot {
1350                snapshot_id: "root-snapshot".to_string(),
1351                module_hash: None,
1352                wasm_hash: None,
1353                code_version: Some("v0.30.3".to_string()),
1354                artifact_path: "artifacts/root".to_string(),
1355                checksum_algorithm: "sha256".to_string(),
1356                checksum: None,
1357            },
1358        }
1359    }
1360
1361    // Build one durable journal with a caller-provided checksum.
1362    fn journal_with_checksum(checksum: String) -> DownloadJournal {
1363        DownloadJournal {
1364            journal_version: 1,
1365            backup_id: "backup-test".to_string(),
1366            discovery_topology_hash: Some(HASH.to_string()),
1367            pre_snapshot_topology_hash: Some(HASH.to_string()),
1368            artifacts: vec![ArtifactJournalEntry {
1369                canister_id: ROOT.to_string(),
1370                snapshot_id: "root-snapshot".to_string(),
1371                state: ArtifactState::Durable,
1372                temp_path: None,
1373                artifact_path: "artifacts/root".to_string(),
1374                checksum_algorithm: "sha256".to_string(),
1375                checksum: Some(checksum),
1376                updated_at: "2026-05-03T00:00:00Z".to_string(),
1377            }],
1378        }
1379    }
1380
1381    // Build one incomplete journal that still needs artifact download work.
1382    fn created_journal() -> DownloadJournal {
1383        DownloadJournal {
1384            journal_version: 1,
1385            backup_id: "backup-test".to_string(),
1386            discovery_topology_hash: Some(HASH.to_string()),
1387            pre_snapshot_topology_hash: Some(HASH.to_string()),
1388            artifacts: vec![ArtifactJournalEntry {
1389                canister_id: ROOT.to_string(),
1390                snapshot_id: "root-snapshot".to_string(),
1391                state: ArtifactState::Created,
1392                temp_path: None,
1393                artifact_path: "artifacts/root".to_string(),
1394                checksum_algorithm: "sha256".to_string(),
1395                checksum: None,
1396                updated_at: "2026-05-03T00:00:00Z".to_string(),
1397            }],
1398        }
1399    }
1400
1401    // Build one ready inspection report for requirement tests.
1402    fn ready_inspection_report() -> BackupInspectionReport {
1403        BackupInspectionReport {
1404            backup_id: "backup-test".to_string(),
1405            manifest_backup_id: "backup-test".to_string(),
1406            journal_backup_id: "backup-test".to_string(),
1407            backup_id_matches: true,
1408            journal_complete: true,
1409            ready_for_verify: true,
1410            manifest_members: 1,
1411            journal_artifacts: 1,
1412            matched_artifacts: 1,
1413            topology_receipt_mismatches: Vec::new(),
1414            missing_journal_artifacts: Vec::new(),
1415            unexpected_journal_artifacts: Vec::new(),
1416            path_mismatches: Vec::new(),
1417            checksum_mismatches: Vec::new(),
1418        }
1419    }
1420
1421    // Build one matching provenance report for requirement tests.
1422    fn ready_provenance_report() -> BackupProvenanceReport {
1423        BackupProvenanceReport {
1424            backup_id: "backup-test".to_string(),
1425            manifest_backup_id: "backup-test".to_string(),
1426            journal_backup_id: "backup-test".to_string(),
1427            backup_id_matches: true,
1428            manifest_version: 1,
1429            journal_version: 1,
1430            created_at: "2026-05-03T00:00:00Z".to_string(),
1431            tool_name: "canic".to_string(),
1432            tool_version: "0.30.12".to_string(),
1433            source_environment: "local".to_string(),
1434            source_root_canister: ROOT.to_string(),
1435            topology_hash_algorithm: "sha256".to_string(),
1436            topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1437            discovery_topology_hash: HASH.to_string(),
1438            pre_snapshot_topology_hash: HASH.to_string(),
1439            accepted_topology_hash: HASH.to_string(),
1440            journal_discovery_topology_hash: Some(HASH.to_string()),
1441            journal_pre_snapshot_topology_hash: Some(HASH.to_string()),
1442            topology_receipts_match: true,
1443            topology_receipt_mismatches: Vec::new(),
1444            backup_unit_count: 1,
1445            member_count: 1,
1446            consistency_mode: "crash-consistent".to_string(),
1447            backup_units: Vec::new(),
1448            members: Vec::new(),
1449        }
1450    }
1451
1452    // Write one artifact at the layout-relative path used by test journals.
1453    fn write_artifact(root: &Path, bytes: &[u8]) -> ArtifactChecksum {
1454        let path = root.join("artifacts/root");
1455        fs::create_dir_all(path.parent().expect("artifact has parent")).expect("create artifacts");
1456        fs::write(&path, bytes).expect("write artifact");
1457        ArtifactChecksum::from_bytes(bytes)
1458    }
1459
1460    // Build a unique temporary directory.
1461    fn temp_dir(prefix: &str) -> PathBuf {
1462        let nanos = SystemTime::now()
1463            .duration_since(UNIX_EPOCH)
1464            .expect("system time after epoch")
1465            .as_nanos();
1466        std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
1467    }
1468}