Skip to main content

canic_cli/backup/
mod.rs

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