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