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