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#[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#[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 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#[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 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#[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#[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
307struct 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
322struct 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
337struct 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
352struct 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#[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 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#[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 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#[derive(Clone, Debug, Eq, PartialEq)]
455pub struct BackupVerifyOptions {
456 pub dir: PathBuf,
457 pub out: Option<PathBuf>,
458}
459
460impl BackupVerifyOptions {
461 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#[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 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
532pub 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
588pub 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
631pub 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
659fn 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
670fn 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
685fn 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
725fn 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
754fn 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
775fn 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
789fn 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
822fn 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
905pub 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
913pub 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
921fn 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
938fn 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
960pub 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
969fn 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
982fn 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
994pub 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
1002fn 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
1020fn 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
1038fn 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
1056fn 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
1074fn 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
1088fn 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
1102fn 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
1111fn 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
1120fn 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
1185fn 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
1213fn 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
1245fn 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
1258fn 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
1305fn 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
1347fn 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
1384fn 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
1411fn 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
1458fn 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
1487fn 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
1510const fn consistency_mode_name(mode: &ConsistencyMode) -> &'static str {
1512 match mode {
1513 ConsistencyMode::CrashConsistent => "crash-consistent",
1514 ConsistencyMode::QuiescedUnit => "quiesced-unit",
1515 }
1516}
1517
1518const 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
1528const fn readiness_status(ready: bool) -> &'static str {
1530 if ready { "ready" } else { "not-ready" }
1531}
1532
1533const fn consistency_status(consistent: bool) -> &'static str {
1535 if consistent {
1536 "consistent"
1537 } else {
1538 "inconsistent"
1539 }
1540}
1541
1542const fn match_status(matches: bool) -> &'static str {
1544 if matches { "matched" } else { "mismatched" }
1545}
1546
1547fn 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
1553fn 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
1563const 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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}