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