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