Skip to main content

canic_cli/backup/
mod.rs

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