1use canic_backup::{
2 journal::JournalResumeReport,
3 manifest::{BackupUnitKind, ConsistencyMode, FleetBackupManifest},
4 persistence::{
5 BackupInspectionReport, BackupIntegrityReport, BackupLayout, BackupProvenanceReport,
6 PersistenceError,
7 },
8 restore::{RestoreMapping, RestorePlanError, RestorePlanner},
9};
10use serde_json::json;
11use std::{
12 ffi::OsString,
13 fs,
14 io::{self, Write},
15 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(transparent)]
72 Io(#[from] std::io::Error),
73
74 #[error(transparent)]
75 Json(#[from] serde_json::Error),
76
77 #[error(transparent)]
78 Persistence(#[from] PersistenceError),
79
80 #[error(transparent)]
81 RestorePlan(#[from] RestorePlanError),
82}
83
84#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct BackupPreflightOptions {
90 pub dir: PathBuf,
91 pub out_dir: PathBuf,
92 pub mapping: Option<PathBuf>,
93}
94
95impl BackupPreflightOptions {
96 pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
98 where
99 I: IntoIterator<Item = OsString>,
100 {
101 let mut dir = None;
102 let mut out_dir = None;
103 let mut mapping = None;
104
105 let mut args = args.into_iter();
106 while let Some(arg) = args.next() {
107 let arg = arg
108 .into_string()
109 .map_err(|_| BackupCommandError::Usage(usage()))?;
110 match arg.as_str() {
111 "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
112 "--out-dir" => out_dir = Some(PathBuf::from(next_value(&mut args, "--out-dir")?)),
113 "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
114 "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
115 _ => return Err(BackupCommandError::UnknownOption(arg)),
116 }
117 }
118
119 Ok(Self {
120 dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
121 out_dir: out_dir.ok_or(BackupCommandError::MissingOption("--out-dir"))?,
122 mapping,
123 })
124 }
125}
126
127#[derive(Clone, Debug, Eq, PartialEq)]
132pub struct BackupPreflightReport {
133 pub status: String,
134 pub backup_id: String,
135 pub backup_dir: String,
136 pub source_environment: String,
137 pub source_root_canister: String,
138 pub topology_hash: String,
139 pub mapping_path: Option<String>,
140 pub journal_complete: bool,
141 pub inspection_status: String,
142 pub provenance_status: String,
143 pub backup_id_status: String,
144 pub topology_receipts_status: String,
145 pub topology_mismatch_count: usize,
146 pub integrity_verified: bool,
147 pub manifest_members: usize,
148 pub backup_unit_count: usize,
149 pub restore_plan_members: usize,
150 pub restore_fixed_members: usize,
151 pub restore_relocatable_members: usize,
152 pub restore_in_place_members: usize,
153 pub restore_mapped_members: usize,
154 pub restore_remapped_members: usize,
155 pub restore_fleet_checks: usize,
156 pub restore_member_check_groups: usize,
157 pub restore_member_checks: usize,
158 pub restore_members_with_checks: usize,
159 pub restore_total_checks: usize,
160 pub restore_phase_count: usize,
161 pub restore_dependency_free_members: usize,
162 pub restore_in_group_parent_edges: usize,
163 pub restore_cross_group_parent_edges: usize,
164 pub manifest_validation_path: String,
165 pub backup_status_path: String,
166 pub backup_inspection_path: String,
167 pub backup_provenance_path: String,
168 pub backup_integrity_path: String,
169 pub restore_plan_path: String,
170 pub preflight_summary_path: String,
171}
172
173#[derive(Clone, Debug, Eq, PartialEq)]
178pub struct BackupInspectOptions {
179 pub dir: PathBuf,
180 pub out: Option<PathBuf>,
181 pub require_ready: bool,
182}
183
184impl BackupInspectOptions {
185 pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
187 where
188 I: IntoIterator<Item = OsString>,
189 {
190 let mut dir = None;
191 let mut out = None;
192 let mut require_ready = false;
193
194 let mut args = args.into_iter();
195 while let Some(arg) = args.next() {
196 let arg = arg
197 .into_string()
198 .map_err(|_| BackupCommandError::Usage(usage()))?;
199 match arg.as_str() {
200 "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
201 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
202 "--require-ready" => require_ready = true,
203 "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
204 _ => return Err(BackupCommandError::UnknownOption(arg)),
205 }
206 }
207
208 Ok(Self {
209 dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
210 out,
211 require_ready,
212 })
213 }
214}
215
216#[derive(Clone, Debug, Eq, PartialEq)]
221pub struct BackupProvenanceOptions {
222 pub dir: PathBuf,
223 pub out: Option<PathBuf>,
224 pub require_consistent: bool,
225}
226
227impl BackupProvenanceOptions {
228 pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
230 where
231 I: IntoIterator<Item = OsString>,
232 {
233 let mut dir = None;
234 let mut out = None;
235 let mut require_consistent = false;
236
237 let mut args = args.into_iter();
238 while let Some(arg) = args.next() {
239 let arg = arg
240 .into_string()
241 .map_err(|_| BackupCommandError::Usage(usage()))?;
242 match arg.as_str() {
243 "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
244 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
245 "--require-consistent" => require_consistent = true,
246 "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
247 _ => return Err(BackupCommandError::UnknownOption(arg)),
248 }
249 }
250
251 Ok(Self {
252 dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
253 out,
254 require_consistent,
255 })
256 }
257}
258
259#[derive(Clone, Debug, Eq, PartialEq)]
264pub struct BackupVerifyOptions {
265 pub dir: PathBuf,
266 pub out: Option<PathBuf>,
267}
268
269impl BackupVerifyOptions {
270 pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
272 where
273 I: IntoIterator<Item = OsString>,
274 {
275 let mut dir = None;
276 let mut out = None;
277
278 let mut args = args.into_iter();
279 while let Some(arg) = args.next() {
280 let arg = arg
281 .into_string()
282 .map_err(|_| BackupCommandError::Usage(usage()))?;
283 match arg.as_str() {
284 "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
285 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
286 "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
287 _ => return Err(BackupCommandError::UnknownOption(arg)),
288 }
289 }
290
291 Ok(Self {
292 dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
293 out,
294 })
295 }
296}
297
298#[derive(Clone, Debug, Eq, PartialEq)]
303pub struct BackupStatusOptions {
304 pub dir: PathBuf,
305 pub out: Option<PathBuf>,
306 pub require_complete: bool,
307}
308
309impl BackupStatusOptions {
310 pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
312 where
313 I: IntoIterator<Item = OsString>,
314 {
315 let mut dir = None;
316 let mut out = None;
317 let mut require_complete = false;
318
319 let mut args = args.into_iter();
320 while let Some(arg) = args.next() {
321 let arg = arg
322 .into_string()
323 .map_err(|_| BackupCommandError::Usage(usage()))?;
324 match arg.as_str() {
325 "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
326 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
327 "--require-complete" => require_complete = true,
328 "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
329 _ => return Err(BackupCommandError::UnknownOption(arg)),
330 }
331 }
332
333 Ok(Self {
334 dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
335 out,
336 require_complete,
337 })
338 }
339}
340
341pub fn run<I>(args: I) -> Result<(), BackupCommandError>
343where
344 I: IntoIterator<Item = OsString>,
345{
346 let mut args = args.into_iter();
347 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
348 return Err(BackupCommandError::Usage(usage()));
349 };
350
351 match command.as_str() {
352 "preflight" => {
353 let options = BackupPreflightOptions::parse(args)?;
354 backup_preflight(&options)?;
355 Ok(())
356 }
357 "inspect" => {
358 let options = BackupInspectOptions::parse(args)?;
359 let report = inspect_backup(&options)?;
360 write_inspect_report(&options, &report)?;
361 enforce_inspection_requirements(&options, &report)?;
362 Ok(())
363 }
364 "provenance" => {
365 let options = BackupProvenanceOptions::parse(args)?;
366 let report = backup_provenance(&options)?;
367 write_provenance_report(&options, &report)?;
368 enforce_provenance_requirements(&options, &report)?;
369 Ok(())
370 }
371 "status" => {
372 let options = BackupStatusOptions::parse(args)?;
373 let report = backup_status(&options)?;
374 write_status_report(&options, &report)?;
375 enforce_status_requirements(&options, &report)?;
376 Ok(())
377 }
378 "verify" => {
379 let options = BackupVerifyOptions::parse(args)?;
380 let report = verify_backup(&options)?;
381 write_report(&options, &report)?;
382 Ok(())
383 }
384 "help" | "--help" | "-h" => Err(BackupCommandError::Usage(usage())),
385 _ => Err(BackupCommandError::UnknownOption(command)),
386 }
387}
388
389pub fn backup_preflight(
391 options: &BackupPreflightOptions,
392) -> Result<BackupPreflightReport, BackupCommandError> {
393 fs::create_dir_all(&options.out_dir)?;
394
395 let layout = BackupLayout::new(options.dir.clone());
396 let manifest = layout.read_manifest()?;
397 let status = layout.read_journal()?.resume_report();
398 ensure_complete_status(&status)?;
399 let inspection = layout.inspect()?;
400 let provenance = layout.provenance()?;
401 let integrity = layout.verify_integrity()?;
402 let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
403 let restore_plan = RestorePlanner::plan(&manifest, mapping.as_ref())?;
404
405 let manifest_validation_path = options.out_dir.join("manifest-validation.json");
406 let backup_status_path = options.out_dir.join("backup-status.json");
407 let backup_inspection_path = options.out_dir.join("backup-inspection.json");
408 let backup_provenance_path = options.out_dir.join("backup-provenance.json");
409 let backup_integrity_path = options.out_dir.join("backup-integrity.json");
410 let restore_plan_path = options.out_dir.join("restore-plan.json");
411 let preflight_summary_path = options.out_dir.join("preflight-summary.json");
412
413 write_json_value_file(
414 &manifest_validation_path,
415 &manifest_validation_summary(&manifest),
416 )?;
417 fs::write(&backup_status_path, serde_json::to_vec_pretty(&status)?)?;
418 fs::write(
419 &backup_inspection_path,
420 serde_json::to_vec_pretty(&inspection)?,
421 )?;
422 fs::write(
423 &backup_provenance_path,
424 serde_json::to_vec_pretty(&provenance)?,
425 )?;
426 fs::write(
427 &backup_integrity_path,
428 serde_json::to_vec_pretty(&integrity)?,
429 )?;
430 fs::write(
431 &restore_plan_path,
432 serde_json::to_vec_pretty(&restore_plan)?,
433 )?;
434
435 let report = BackupPreflightReport {
436 status: "ready".to_string(),
437 backup_id: manifest.backup_id.clone(),
438 backup_dir: options.dir.display().to_string(),
439 source_environment: manifest.source.environment.clone(),
440 source_root_canister: manifest.source.root_canister.clone(),
441 topology_hash: manifest.fleet.topology_hash.clone(),
442 mapping_path: options
443 .mapping
444 .as_ref()
445 .map(|path| path.display().to_string()),
446 journal_complete: status.is_complete,
447 inspection_status: readiness_status(inspection.ready_for_verify).to_string(),
448 provenance_status: consistency_status(
449 provenance.backup_id_matches && provenance.topology_receipts_match,
450 )
451 .to_string(),
452 backup_id_status: match_status(provenance.backup_id_matches).to_string(),
453 topology_receipts_status: match_status(provenance.topology_receipts_match).to_string(),
454 topology_mismatch_count: provenance.topology_receipt_mismatches.len(),
455 integrity_verified: integrity.verified,
456 manifest_members: manifest.fleet.members.len(),
457 backup_unit_count: provenance.backup_unit_count,
458 restore_plan_members: restore_plan.member_count,
459 restore_fixed_members: restore_plan.identity_summary.fixed_members,
460 restore_relocatable_members: restore_plan.identity_summary.relocatable_members,
461 restore_in_place_members: restore_plan.identity_summary.in_place_members,
462 restore_mapped_members: restore_plan.identity_summary.mapped_members,
463 restore_remapped_members: restore_plan.identity_summary.remapped_members,
464 restore_fleet_checks: restore_plan.verification_summary.fleet_checks,
465 restore_member_check_groups: restore_plan.verification_summary.member_check_groups,
466 restore_member_checks: restore_plan.verification_summary.member_checks,
467 restore_members_with_checks: restore_plan.verification_summary.members_with_checks,
468 restore_total_checks: restore_plan.verification_summary.total_checks,
469 restore_phase_count: restore_plan.ordering_summary.phase_count,
470 restore_dependency_free_members: restore_plan.ordering_summary.dependency_free_members,
471 restore_in_group_parent_edges: restore_plan.ordering_summary.in_group_parent_edges,
472 restore_cross_group_parent_edges: restore_plan.ordering_summary.cross_group_parent_edges,
473 manifest_validation_path: manifest_validation_path.display().to_string(),
474 backup_status_path: backup_status_path.display().to_string(),
475 backup_inspection_path: backup_inspection_path.display().to_string(),
476 backup_provenance_path: backup_provenance_path.display().to_string(),
477 backup_integrity_path: backup_integrity_path.display().to_string(),
478 restore_plan_path: restore_plan_path.display().to_string(),
479 preflight_summary_path: preflight_summary_path.display().to_string(),
480 };
481
482 write_json_value_file(&preflight_summary_path, &preflight_summary_value(&report))?;
483 Ok(report)
484}
485
486pub fn inspect_backup(
488 options: &BackupInspectOptions,
489) -> Result<BackupInspectionReport, BackupCommandError> {
490 let layout = BackupLayout::new(options.dir.clone());
491 layout.inspect().map_err(BackupCommandError::from)
492}
493
494pub fn backup_provenance(
496 options: &BackupProvenanceOptions,
497) -> Result<BackupProvenanceReport, BackupCommandError> {
498 let layout = BackupLayout::new(options.dir.clone());
499 layout.provenance().map_err(BackupCommandError::from)
500}
501
502fn enforce_provenance_requirements(
504 options: &BackupProvenanceOptions,
505 report: &BackupProvenanceReport,
506) -> Result<(), BackupCommandError> {
507 if !options.require_consistent || (report.backup_id_matches && report.topology_receipts_match) {
508 return Ok(());
509 }
510
511 Err(BackupCommandError::ProvenanceNotConsistent {
512 backup_id: report.backup_id.clone(),
513 backup_id_matches: report.backup_id_matches,
514 topology_receipts_match: report.topology_receipts_match,
515 topology_mismatches: report.topology_receipt_mismatches.len(),
516 })
517}
518
519fn enforce_inspection_requirements(
521 options: &BackupInspectOptions,
522 report: &BackupInspectionReport,
523) -> Result<(), BackupCommandError> {
524 if !options.require_ready || report.ready_for_verify {
525 return Ok(());
526 }
527
528 Err(BackupCommandError::InspectionNotReady {
529 backup_id: report.backup_id.clone(),
530 backup_id_matches: report.backup_id_matches,
531 topology_receipts_match: report.topology_receipt_mismatches.is_empty(),
532 journal_complete: report.journal_complete,
533 topology_mismatches: report.topology_receipt_mismatches.len(),
534 missing_artifacts: report.missing_journal_artifacts.len(),
535 unexpected_artifacts: report.unexpected_journal_artifacts.len(),
536 path_mismatches: report.path_mismatches.len(),
537 checksum_mismatches: report.checksum_mismatches.len(),
538 })
539}
540
541pub fn backup_status(
543 options: &BackupStatusOptions,
544) -> Result<JournalResumeReport, BackupCommandError> {
545 let layout = BackupLayout::new(options.dir.clone());
546 let journal = layout.read_journal()?;
547 Ok(journal.resume_report())
548}
549
550fn ensure_complete_status(report: &JournalResumeReport) -> Result<(), BackupCommandError> {
552 if report.is_complete {
553 return Ok(());
554 }
555
556 Err(BackupCommandError::IncompleteJournal {
557 backup_id: report.backup_id.clone(),
558 total_artifacts: report.total_artifacts,
559 pending_artifacts: report.pending_artifacts,
560 })
561}
562
563fn enforce_status_requirements(
565 options: &BackupStatusOptions,
566 report: &JournalResumeReport,
567) -> Result<(), BackupCommandError> {
568 if !options.require_complete {
569 return Ok(());
570 }
571
572 ensure_complete_status(report)
573}
574
575pub fn verify_backup(
577 options: &BackupVerifyOptions,
578) -> Result<BackupIntegrityReport, BackupCommandError> {
579 let layout = BackupLayout::new(options.dir.clone());
580 layout.verify_integrity().map_err(BackupCommandError::from)
581}
582
583fn write_status_report(
585 options: &BackupStatusOptions,
586 report: &JournalResumeReport,
587) -> Result<(), BackupCommandError> {
588 if let Some(path) = &options.out {
589 let data = serde_json::to_vec_pretty(report)?;
590 fs::write(path, data)?;
591 return Ok(());
592 }
593
594 let stdout = io::stdout();
595 let mut handle = stdout.lock();
596 serde_json::to_writer_pretty(&mut handle, report)?;
597 writeln!(handle)?;
598 Ok(())
599}
600
601fn write_inspect_report(
603 options: &BackupInspectOptions,
604 report: &BackupInspectionReport,
605) -> Result<(), BackupCommandError> {
606 if let Some(path) = &options.out {
607 let data = serde_json::to_vec_pretty(report)?;
608 fs::write(path, data)?;
609 return Ok(());
610 }
611
612 let stdout = io::stdout();
613 let mut handle = stdout.lock();
614 serde_json::to_writer_pretty(&mut handle, report)?;
615 writeln!(handle)?;
616 Ok(())
617}
618
619fn write_provenance_report(
621 options: &BackupProvenanceOptions,
622 report: &BackupProvenanceReport,
623) -> Result<(), BackupCommandError> {
624 if let Some(path) = &options.out {
625 let data = serde_json::to_vec_pretty(report)?;
626 fs::write(path, data)?;
627 return Ok(());
628 }
629
630 let stdout = io::stdout();
631 let mut handle = stdout.lock();
632 serde_json::to_writer_pretty(&mut handle, report)?;
633 writeln!(handle)?;
634 Ok(())
635}
636
637fn write_report(
639 options: &BackupVerifyOptions,
640 report: &BackupIntegrityReport,
641) -> Result<(), BackupCommandError> {
642 if let Some(path) = &options.out {
643 let data = serde_json::to_vec_pretty(report)?;
644 fs::write(path, data)?;
645 return Ok(());
646 }
647
648 let stdout = io::stdout();
649 let mut handle = stdout.lock();
650 serde_json::to_writer_pretty(&mut handle, report)?;
651 writeln!(handle)?;
652 Ok(())
653}
654
655fn write_json_value_file(
657 path: &PathBuf,
658 value: &serde_json::Value,
659) -> Result<(), BackupCommandError> {
660 if let Some(parent) = path.parent() {
661 fs::create_dir_all(parent)?;
662 }
663
664 let data = serde_json::to_vec_pretty(value)?;
665 fs::write(path, data)?;
666 Ok(())
667}
668
669fn preflight_summary_value(report: &BackupPreflightReport) -> serde_json::Value {
671 json!({
672 "status": report.status,
673 "backup_id": report.backup_id,
674 "backup_dir": report.backup_dir,
675 "source_environment": report.source_environment,
676 "source_root_canister": report.source_root_canister,
677 "topology_hash": report.topology_hash,
678 "mapping_path": report.mapping_path,
679 "journal_complete": report.journal_complete,
680 "inspection_status": report.inspection_status,
681 "provenance_status": report.provenance_status,
682 "backup_id_status": report.backup_id_status,
683 "topology_receipts_status": report.topology_receipts_status,
684 "topology_mismatch_count": report.topology_mismatch_count,
685 "integrity_verified": report.integrity_verified,
686 "manifest_members": report.manifest_members,
687 "backup_unit_count": report.backup_unit_count,
688 "restore_plan_members": report.restore_plan_members,
689 "restore_fixed_members": report.restore_fixed_members,
690 "restore_relocatable_members": report.restore_relocatable_members,
691 "restore_in_place_members": report.restore_in_place_members,
692 "restore_mapped_members": report.restore_mapped_members,
693 "restore_remapped_members": report.restore_remapped_members,
694 "restore_fleet_checks": report.restore_fleet_checks,
695 "restore_member_check_groups": report.restore_member_check_groups,
696 "restore_member_checks": report.restore_member_checks,
697 "restore_members_with_checks": report.restore_members_with_checks,
698 "restore_total_checks": report.restore_total_checks,
699 "restore_phase_count": report.restore_phase_count,
700 "restore_dependency_free_members": report.restore_dependency_free_members,
701 "restore_in_group_parent_edges": report.restore_in_group_parent_edges,
702 "restore_cross_group_parent_edges": report.restore_cross_group_parent_edges,
703 "manifest_validation_path": report.manifest_validation_path,
704 "backup_status_path": report.backup_status_path,
705 "backup_inspection_path": report.backup_inspection_path,
706 "backup_provenance_path": report.backup_provenance_path,
707 "backup_integrity_path": report.backup_integrity_path,
708 "restore_plan_path": report.restore_plan_path,
709 "preflight_summary_path": report.preflight_summary_path,
710 })
711}
712
713fn manifest_validation_summary(manifest: &FleetBackupManifest) -> serde_json::Value {
715 json!({
716 "status": "valid",
717 "backup_id": manifest.backup_id,
718 "members": manifest.fleet.members.len(),
719 "backup_unit_count": manifest.consistency.backup_units.len(),
720 "consistency_mode": consistency_mode_name(&manifest.consistency.mode),
721 "topology_hash": manifest.fleet.topology_hash,
722 "topology_hash_algorithm": manifest.fleet.topology_hash_algorithm,
723 "topology_hash_input": manifest.fleet.topology_hash_input,
724 "topology_validation_status": "validated",
725 "backup_unit_kinds": backup_unit_kind_counts(manifest),
726 "backup_units": manifest
727 .consistency
728 .backup_units
729 .iter()
730 .map(|unit| json!({
731 "unit_id": unit.unit_id,
732 "kind": backup_unit_kind_name(&unit.kind),
733 "role_count": unit.roles.len(),
734 "dependency_count": unit.dependency_closure.len(),
735 "topology_validation": unit.topology_validation,
736 }))
737 .collect::<Vec<_>>(),
738 })
739}
740
741fn backup_unit_kind_counts(manifest: &FleetBackupManifest) -> serde_json::Value {
743 let mut whole_fleet = 0;
744 let mut control_plane_subset = 0;
745 let mut subtree_rooted = 0;
746 let mut flat = 0;
747 for unit in &manifest.consistency.backup_units {
748 match &unit.kind {
749 BackupUnitKind::WholeFleet => whole_fleet += 1,
750 BackupUnitKind::ControlPlaneSubset => control_plane_subset += 1,
751 BackupUnitKind::SubtreeRooted => subtree_rooted += 1,
752 BackupUnitKind::Flat => flat += 1,
753 }
754 }
755
756 json!({
757 "whole_fleet": whole_fleet,
758 "control_plane_subset": control_plane_subset,
759 "subtree_rooted": subtree_rooted,
760 "flat": flat,
761 })
762}
763
764const fn consistency_mode_name(mode: &ConsistencyMode) -> &'static str {
766 match mode {
767 ConsistencyMode::CrashConsistent => "crash-consistent",
768 ConsistencyMode::QuiescedUnit => "quiesced-unit",
769 }
770}
771
772const fn backup_unit_kind_name(kind: &BackupUnitKind) -> &'static str {
774 match kind {
775 BackupUnitKind::WholeFleet => "whole-fleet",
776 BackupUnitKind::ControlPlaneSubset => "control-plane-subset",
777 BackupUnitKind::SubtreeRooted => "subtree-rooted",
778 BackupUnitKind::Flat => "flat",
779 }
780}
781
782const fn readiness_status(ready: bool) -> &'static str {
784 if ready { "ready" } else { "not-ready" }
785}
786
787const fn consistency_status(consistent: bool) -> &'static str {
789 if consistent {
790 "consistent"
791 } else {
792 "inconsistent"
793 }
794}
795
796const fn match_status(matches: bool) -> &'static str {
798 if matches { "matched" } else { "mismatched" }
799}
800
801fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, BackupCommandError> {
803 let data = fs::read_to_string(path)?;
804 serde_json::from_str(&data).map_err(BackupCommandError::from)
805}
806
807fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, BackupCommandError>
809where
810 I: Iterator<Item = OsString>,
811{
812 args.next()
813 .and_then(|value| value.into_string().ok())
814 .ok_or(BackupCommandError::MissingValue(option))
815}
816
817const fn usage() -> &'static str {
819 "usage: canic backup preflight --dir <backup-dir> --out-dir <dir> [--mapping <file>]\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>]"
820}
821
822#[cfg(test)]
823mod tests {
824 use super::*;
825 use canic_backup::{
826 artifacts::ArtifactChecksum,
827 journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
828 manifest::{
829 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetBackupManifest,
830 FleetMember, FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
831 VerificationCheck, VerificationPlan,
832 },
833 };
834 use std::{
835 fs,
836 path::Path,
837 time::{SystemTime, UNIX_EPOCH},
838 };
839
840 const ROOT: &str = "aaaaa-aa";
841 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
842
843 #[test]
845 fn parses_backup_preflight_options() {
846 let options = BackupPreflightOptions::parse([
847 OsString::from("--dir"),
848 OsString::from("backups/run"),
849 OsString::from("--out-dir"),
850 OsString::from("reports/run"),
851 OsString::from("--mapping"),
852 OsString::from("mapping.json"),
853 ])
854 .expect("parse options");
855
856 assert_eq!(options.dir, PathBuf::from("backups/run"));
857 assert_eq!(options.out_dir, PathBuf::from("reports/run"));
858 assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
859 }
860
861 #[test]
863 fn backup_preflight_writes_standard_reports() {
864 let root = temp_dir("canic-cli-backup-preflight");
865 let out_dir = root.join("reports");
866 let backup_dir = root.join("backup");
867 let layout = BackupLayout::new(backup_dir.clone());
868 let checksum = write_artifact(&backup_dir, b"root artifact");
869
870 layout
871 .write_manifest(&valid_manifest())
872 .expect("write manifest");
873 layout
874 .write_journal(&journal_with_checksum(checksum.hash))
875 .expect("write journal");
876
877 let options = BackupPreflightOptions {
878 dir: backup_dir,
879 out_dir: out_dir.clone(),
880 mapping: None,
881 };
882 let report = backup_preflight(&options).expect("run preflight");
883
884 assert_eq!(report.status, "ready");
885 assert_eq!(report.backup_id, "backup-test");
886 assert_eq!(report.source_environment, "local");
887 assert_eq!(report.source_root_canister, ROOT);
888 assert_eq!(report.topology_hash, HASH);
889 assert_eq!(report.mapping_path, None);
890 assert!(report.journal_complete);
891 assert_eq!(report.inspection_status, "ready");
892 assert_eq!(report.provenance_status, "consistent");
893 assert_eq!(report.backup_id_status, "matched");
894 assert_eq!(report.topology_receipts_status, "matched");
895 assert_eq!(report.topology_mismatch_count, 0);
896 assert!(report.integrity_verified);
897 assert_eq!(report.manifest_members, 1);
898 assert_eq!(report.backup_unit_count, 1);
899 assert_eq!(report.restore_plan_members, 1);
900 assert_preflight_report_restore_counts(&report);
901 assert!(out_dir.join("manifest-validation.json").exists());
902 assert!(out_dir.join("backup-status.json").exists());
903 assert!(out_dir.join("backup-inspection.json").exists());
904 assert!(out_dir.join("backup-provenance.json").exists());
905 assert!(out_dir.join("backup-integrity.json").exists());
906 assert!(out_dir.join("restore-plan.json").exists());
907 assert!(out_dir.join("preflight-summary.json").exists());
908
909 let summary: serde_json::Value = serde_json::from_slice(
910 &fs::read(out_dir.join("preflight-summary.json")).expect("read summary"),
911 )
912 .expect("decode summary");
913 let manifest_validation: serde_json::Value = serde_json::from_slice(
914 &fs::read(out_dir.join("manifest-validation.json")).expect("read manifest summary"),
915 )
916 .expect("decode manifest summary");
917
918 fs::remove_dir_all(root).expect("remove temp root");
919 assert_preflight_summary_matches_report(&summary, &report);
920 assert_eq!(manifest_validation["backup_unit_count"], 1);
921 assert_eq!(manifest_validation["consistency_mode"], "crash-consistent");
922 assert_eq!(
923 manifest_validation["topology_validation_status"],
924 "validated"
925 );
926 assert_eq!(
927 manifest_validation["backup_unit_kinds"]["subtree_rooted"],
928 1
929 );
930 assert_eq!(
931 manifest_validation["backup_units"][0]["kind"],
932 "subtree-rooted"
933 );
934 }
935
936 fn assert_preflight_report_restore_counts(report: &BackupPreflightReport) {
938 assert_eq!(report.restore_fixed_members, 1);
939 assert_eq!(report.restore_relocatable_members, 0);
940 assert_eq!(report.restore_in_place_members, 1);
941 assert_eq!(report.restore_mapped_members, 0);
942 assert_eq!(report.restore_remapped_members, 0);
943 assert_eq!(report.restore_fleet_checks, 0);
944 assert_eq!(report.restore_member_check_groups, 0);
945 assert_eq!(report.restore_member_checks, 1);
946 assert_eq!(report.restore_members_with_checks, 1);
947 assert_eq!(report.restore_total_checks, 1);
948 assert_eq!(report.restore_phase_count, 1);
949 assert_eq!(report.restore_dependency_free_members, 1);
950 assert_eq!(report.restore_in_group_parent_edges, 0);
951 assert_eq!(report.restore_cross_group_parent_edges, 0);
952 }
953
954 fn assert_preflight_summary_matches_report(
956 summary: &serde_json::Value,
957 report: &BackupPreflightReport,
958 ) {
959 assert_eq!(summary["status"], report.status);
960 assert_eq!(summary["backup_id"], report.backup_id);
961 assert_eq!(summary["source_environment"], report.source_environment);
962 assert_eq!(summary["source_root_canister"], report.source_root_canister);
963 assert_eq!(summary["topology_hash"], report.topology_hash);
964 assert_eq!(summary["journal_complete"], report.journal_complete);
965 assert_eq!(summary["inspection_status"], report.inspection_status);
966 assert_eq!(summary["provenance_status"], report.provenance_status);
967 assert_eq!(summary["backup_id_status"], report.backup_id_status);
968 assert_eq!(
969 summary["topology_receipts_status"],
970 report.topology_receipts_status
971 );
972 assert_eq!(
973 summary["topology_mismatch_count"],
974 report.topology_mismatch_count
975 );
976 assert_eq!(summary["integrity_verified"], report.integrity_verified);
977 assert_eq!(summary["manifest_members"], report.manifest_members);
978 assert_eq!(summary["backup_unit_count"], report.backup_unit_count);
979 assert_eq!(summary["restore_plan_members"], report.restore_plan_members);
980 assert_eq!(
981 summary["restore_fixed_members"],
982 report.restore_fixed_members
983 );
984 assert_eq!(
985 summary["restore_relocatable_members"],
986 report.restore_relocatable_members
987 );
988 assert_eq!(
989 summary["restore_in_place_members"],
990 report.restore_in_place_members
991 );
992 assert_eq!(
993 summary["restore_mapped_members"],
994 report.restore_mapped_members
995 );
996 assert_eq!(
997 summary["restore_remapped_members"],
998 report.restore_remapped_members
999 );
1000 assert_eq!(summary["restore_fleet_checks"], report.restore_fleet_checks);
1001 assert_eq!(
1002 summary["restore_member_check_groups"],
1003 report.restore_member_check_groups
1004 );
1005 assert_eq!(
1006 summary["restore_member_checks"],
1007 report.restore_member_checks
1008 );
1009 assert_eq!(
1010 summary["restore_members_with_checks"],
1011 report.restore_members_with_checks
1012 );
1013 assert_eq!(summary["restore_total_checks"], report.restore_total_checks);
1014 assert_eq!(summary["restore_phase_count"], report.restore_phase_count);
1015 assert_eq!(
1016 summary["restore_dependency_free_members"],
1017 report.restore_dependency_free_members
1018 );
1019 assert_eq!(
1020 summary["restore_in_group_parent_edges"],
1021 report.restore_in_group_parent_edges
1022 );
1023 assert_eq!(
1024 summary["restore_cross_group_parent_edges"],
1025 report.restore_cross_group_parent_edges
1026 );
1027 assert_eq!(
1028 summary["backup_inspection_path"],
1029 report.backup_inspection_path
1030 );
1031 assert_eq!(
1032 summary["backup_provenance_path"],
1033 report.backup_provenance_path
1034 );
1035 }
1036
1037 #[test]
1039 fn backup_preflight_rejects_incomplete_journal() {
1040 let root = temp_dir("canic-cli-backup-preflight-incomplete");
1041 let out_dir = root.join("reports");
1042 let backup_dir = root.join("backup");
1043 let layout = BackupLayout::new(backup_dir.clone());
1044
1045 layout
1046 .write_manifest(&valid_manifest())
1047 .expect("write manifest");
1048 layout
1049 .write_journal(&created_journal())
1050 .expect("write journal");
1051
1052 let options = BackupPreflightOptions {
1053 dir: backup_dir,
1054 out_dir,
1055 mapping: None,
1056 };
1057
1058 let err = backup_preflight(&options).expect_err("incomplete journal should fail");
1059
1060 fs::remove_dir_all(root).expect("remove temp root");
1061 assert!(matches!(
1062 err,
1063 BackupCommandError::IncompleteJournal {
1064 pending_artifacts: 1,
1065 total_artifacts: 1,
1066 ..
1067 }
1068 ));
1069 }
1070
1071 #[test]
1073 fn parses_backup_verify_options() {
1074 let options = BackupVerifyOptions::parse([
1075 OsString::from("--dir"),
1076 OsString::from("backups/run"),
1077 OsString::from("--out"),
1078 OsString::from("report.json"),
1079 ])
1080 .expect("parse options");
1081
1082 assert_eq!(options.dir, PathBuf::from("backups/run"));
1083 assert_eq!(options.out, Some(PathBuf::from("report.json")));
1084 }
1085
1086 #[test]
1088 fn parses_backup_inspect_options() {
1089 let options = BackupInspectOptions::parse([
1090 OsString::from("--dir"),
1091 OsString::from("backups/run"),
1092 OsString::from("--out"),
1093 OsString::from("inspect.json"),
1094 OsString::from("--require-ready"),
1095 ])
1096 .expect("parse options");
1097
1098 assert_eq!(options.dir, PathBuf::from("backups/run"));
1099 assert_eq!(options.out, Some(PathBuf::from("inspect.json")));
1100 assert!(options.require_ready);
1101 }
1102
1103 #[test]
1105 fn parses_backup_provenance_options() {
1106 let options = BackupProvenanceOptions::parse([
1107 OsString::from("--dir"),
1108 OsString::from("backups/run"),
1109 OsString::from("--out"),
1110 OsString::from("provenance.json"),
1111 OsString::from("--require-consistent"),
1112 ])
1113 .expect("parse options");
1114
1115 assert_eq!(options.dir, PathBuf::from("backups/run"));
1116 assert_eq!(options.out, Some(PathBuf::from("provenance.json")));
1117 assert!(options.require_consistent);
1118 }
1119
1120 #[test]
1122 fn parses_backup_status_options() {
1123 let options = BackupStatusOptions::parse([
1124 OsString::from("--dir"),
1125 OsString::from("backups/run"),
1126 OsString::from("--out"),
1127 OsString::from("status.json"),
1128 OsString::from("--require-complete"),
1129 ])
1130 .expect("parse options");
1131
1132 assert_eq!(options.dir, PathBuf::from("backups/run"));
1133 assert_eq!(options.out, Some(PathBuf::from("status.json")));
1134 assert!(options.require_complete);
1135 }
1136
1137 #[test]
1139 fn backup_status_reads_journal_resume_report() {
1140 let root = temp_dir("canic-cli-backup-status");
1141 let layout = BackupLayout::new(root.clone());
1142 layout
1143 .write_journal(&journal_with_checksum(HASH.to_string()))
1144 .expect("write journal");
1145
1146 let options = BackupStatusOptions {
1147 dir: root.clone(),
1148 out: None,
1149 require_complete: false,
1150 };
1151 let report = backup_status(&options).expect("read backup status");
1152
1153 fs::remove_dir_all(root).expect("remove temp root");
1154 assert_eq!(report.backup_id, "backup-test");
1155 assert_eq!(report.total_artifacts, 1);
1156 assert!(report.is_complete);
1157 assert_eq!(report.pending_artifacts, 0);
1158 assert_eq!(report.counts.skip, 1);
1159 }
1160
1161 #[test]
1163 fn inspect_backup_reads_layout_metadata() {
1164 let root = temp_dir("canic-cli-backup-inspect");
1165 let layout = BackupLayout::new(root.clone());
1166
1167 layout
1168 .write_manifest(&valid_manifest())
1169 .expect("write manifest");
1170 layout
1171 .write_journal(&journal_with_checksum(HASH.to_string()))
1172 .expect("write journal");
1173
1174 let options = BackupInspectOptions {
1175 dir: root.clone(),
1176 out: None,
1177 require_ready: false,
1178 };
1179 let report = inspect_backup(&options).expect("inspect backup");
1180
1181 fs::remove_dir_all(root).expect("remove temp root");
1182 assert_eq!(report.backup_id, "backup-test");
1183 assert!(report.backup_id_matches);
1184 assert!(report.journal_complete);
1185 assert!(report.ready_for_verify);
1186 assert!(report.topology_receipt_mismatches.is_empty());
1187 assert_eq!(report.matched_artifacts, 1);
1188 }
1189
1190 #[test]
1192 fn backup_provenance_reads_layout_metadata() {
1193 let root = temp_dir("canic-cli-backup-provenance");
1194 let layout = BackupLayout::new(root.clone());
1195
1196 layout
1197 .write_manifest(&valid_manifest())
1198 .expect("write manifest");
1199 layout
1200 .write_journal(&journal_with_checksum(HASH.to_string()))
1201 .expect("write journal");
1202
1203 let options = BackupProvenanceOptions {
1204 dir: root.clone(),
1205 out: None,
1206 require_consistent: false,
1207 };
1208 let report = backup_provenance(&options).expect("read provenance");
1209
1210 fs::remove_dir_all(root).expect("remove temp root");
1211 assert_eq!(report.backup_id, "backup-test");
1212 assert!(report.backup_id_matches);
1213 assert_eq!(report.source_environment, "local");
1214 assert_eq!(report.discovery_topology_hash, HASH);
1215 assert!(report.topology_receipts_match);
1216 assert!(report.topology_receipt_mismatches.is_empty());
1217 assert_eq!(report.backup_unit_count, 1);
1218 assert_eq!(report.member_count, 1);
1219 assert_eq!(report.backup_units[0].kind, "subtree-rooted");
1220 assert_eq!(report.members[0].canister_id, ROOT);
1221 assert_eq!(report.members[0].snapshot_id, "root-snapshot");
1222 assert_eq!(report.members[0].journal_state, Some("Durable".to_string()));
1223 }
1224
1225 #[test]
1227 fn require_consistent_accepts_matching_provenance() {
1228 let options = BackupProvenanceOptions {
1229 dir: PathBuf::from("unused"),
1230 out: None,
1231 require_consistent: true,
1232 };
1233 let report = ready_provenance_report();
1234
1235 enforce_provenance_requirements(&options, &report)
1236 .expect("matching provenance should pass");
1237 }
1238
1239 #[test]
1241 fn require_consistent_rejects_provenance_drift() {
1242 let options = BackupProvenanceOptions {
1243 dir: PathBuf::from("unused"),
1244 out: None,
1245 require_consistent: true,
1246 };
1247 let mut report = ready_provenance_report();
1248 report.backup_id_matches = false;
1249 report.journal_backup_id = "other-backup".to_string();
1250 report.topology_receipts_match = false;
1251 report.topology_receipt_mismatches.push(
1252 canic_backup::persistence::TopologyReceiptMismatch {
1253 field: "pre_snapshot_topology_hash".to_string(),
1254 manifest: HASH.to_string(),
1255 journal: None,
1256 },
1257 );
1258
1259 let err = enforce_provenance_requirements(&options, &report)
1260 .expect_err("provenance drift should fail");
1261
1262 assert!(matches!(
1263 err,
1264 BackupCommandError::ProvenanceNotConsistent {
1265 backup_id_matches: false,
1266 topology_receipts_match: false,
1267 topology_mismatches: 1,
1268 ..
1269 }
1270 ));
1271 }
1272
1273 #[test]
1275 fn require_ready_accepts_ready_inspection() {
1276 let options = BackupInspectOptions {
1277 dir: PathBuf::from("unused"),
1278 out: None,
1279 require_ready: true,
1280 };
1281 let report = ready_inspection_report();
1282
1283 enforce_inspection_requirements(&options, &report).expect("ready inspection should pass");
1284 }
1285
1286 #[test]
1288 fn require_ready_rejects_unready_inspection() {
1289 let options = BackupInspectOptions {
1290 dir: PathBuf::from("unused"),
1291 out: None,
1292 require_ready: true,
1293 };
1294 let mut report = ready_inspection_report();
1295 report.ready_for_verify = false;
1296 report
1297 .path_mismatches
1298 .push(canic_backup::persistence::ArtifactPathMismatch {
1299 canister_id: ROOT.to_string(),
1300 snapshot_id: "root-snapshot".to_string(),
1301 manifest: "artifacts/root".to_string(),
1302 journal: "artifacts/other-root".to_string(),
1303 });
1304
1305 let err = enforce_inspection_requirements(&options, &report)
1306 .expect_err("unready inspection should fail");
1307
1308 assert!(matches!(
1309 err,
1310 BackupCommandError::InspectionNotReady {
1311 path_mismatches: 1,
1312 ..
1313 }
1314 ));
1315 }
1316
1317 #[test]
1319 fn require_ready_rejects_topology_receipt_drift() {
1320 let options = BackupInspectOptions {
1321 dir: PathBuf::from("unused"),
1322 out: None,
1323 require_ready: true,
1324 };
1325 let mut report = ready_inspection_report();
1326 report.ready_for_verify = false;
1327 report.topology_receipt_mismatches.push(
1328 canic_backup::persistence::TopologyReceiptMismatch {
1329 field: "discovery_topology_hash".to_string(),
1330 manifest: HASH.to_string(),
1331 journal: None,
1332 },
1333 );
1334
1335 let err = enforce_inspection_requirements(&options, &report)
1336 .expect_err("topology receipt drift should fail");
1337
1338 assert!(matches!(
1339 err,
1340 BackupCommandError::InspectionNotReady {
1341 topology_receipts_match: false,
1342 topology_mismatches: 1,
1343 ..
1344 }
1345 ));
1346 }
1347
1348 #[test]
1350 fn require_complete_accepts_complete_status() {
1351 let options = BackupStatusOptions {
1352 dir: PathBuf::from("unused"),
1353 out: None,
1354 require_complete: true,
1355 };
1356 let report = journal_with_checksum(HASH.to_string()).resume_report();
1357
1358 enforce_status_requirements(&options, &report).expect("complete status should pass");
1359 }
1360
1361 #[test]
1363 fn require_complete_rejects_incomplete_status() {
1364 let options = BackupStatusOptions {
1365 dir: PathBuf::from("unused"),
1366 out: None,
1367 require_complete: true,
1368 };
1369 let report = created_journal().resume_report();
1370
1371 let err = enforce_status_requirements(&options, &report)
1372 .expect_err("incomplete status should fail");
1373
1374 assert!(matches!(
1375 err,
1376 BackupCommandError::IncompleteJournal {
1377 pending_artifacts: 1,
1378 total_artifacts: 1,
1379 ..
1380 }
1381 ));
1382 }
1383
1384 #[test]
1386 fn verify_backup_reads_layout_and_artifacts() {
1387 let root = temp_dir("canic-cli-backup-verify");
1388 let layout = BackupLayout::new(root.clone());
1389 let checksum = write_artifact(&root, b"root artifact");
1390
1391 layout
1392 .write_manifest(&valid_manifest())
1393 .expect("write manifest");
1394 layout
1395 .write_journal(&journal_with_checksum(checksum.hash.clone()))
1396 .expect("write journal");
1397
1398 let options = BackupVerifyOptions {
1399 dir: root.clone(),
1400 out: None,
1401 };
1402 let report = verify_backup(&options).expect("verify backup");
1403
1404 fs::remove_dir_all(root).expect("remove temp root");
1405 assert_eq!(report.backup_id, "backup-test");
1406 assert!(report.verified);
1407 assert_eq!(report.durable_artifacts, 1);
1408 assert_eq!(report.artifacts[0].checksum, checksum.hash);
1409 }
1410
1411 fn valid_manifest() -> FleetBackupManifest {
1413 FleetBackupManifest {
1414 manifest_version: 1,
1415 backup_id: "backup-test".to_string(),
1416 created_at: "2026-05-03T00:00:00Z".to_string(),
1417 tool: ToolMetadata {
1418 name: "canic".to_string(),
1419 version: "0.30.3".to_string(),
1420 },
1421 source: SourceMetadata {
1422 environment: "local".to_string(),
1423 root_canister: ROOT.to_string(),
1424 },
1425 consistency: ConsistencySection {
1426 mode: ConsistencyMode::CrashConsistent,
1427 backup_units: vec![BackupUnit {
1428 unit_id: "fleet".to_string(),
1429 kind: BackupUnitKind::SubtreeRooted,
1430 roles: vec!["root".to_string()],
1431 consistency_reason: None,
1432 dependency_closure: Vec::new(),
1433 topology_validation: "subtree-closed".to_string(),
1434 quiescence_strategy: None,
1435 }],
1436 },
1437 fleet: FleetSection {
1438 topology_hash_algorithm: "sha256".to_string(),
1439 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1440 discovery_topology_hash: HASH.to_string(),
1441 pre_snapshot_topology_hash: HASH.to_string(),
1442 topology_hash: HASH.to_string(),
1443 members: vec![fleet_member()],
1444 },
1445 verification: VerificationPlan::default(),
1446 }
1447 }
1448
1449 fn fleet_member() -> FleetMember {
1451 FleetMember {
1452 role: "root".to_string(),
1453 canister_id: ROOT.to_string(),
1454 parent_canister_id: None,
1455 subnet_canister_id: Some(ROOT.to_string()),
1456 controller_hint: None,
1457 identity_mode: IdentityMode::Fixed,
1458 restore_group: 1,
1459 verification_class: "basic".to_string(),
1460 verification_checks: vec![VerificationCheck {
1461 kind: "status".to_string(),
1462 method: None,
1463 roles: vec!["root".to_string()],
1464 }],
1465 source_snapshot: SourceSnapshot {
1466 snapshot_id: "root-snapshot".to_string(),
1467 module_hash: None,
1468 wasm_hash: None,
1469 code_version: Some("v0.30.3".to_string()),
1470 artifact_path: "artifacts/root".to_string(),
1471 checksum_algorithm: "sha256".to_string(),
1472 checksum: None,
1473 },
1474 }
1475 }
1476
1477 fn journal_with_checksum(checksum: String) -> DownloadJournal {
1479 DownloadJournal {
1480 journal_version: 1,
1481 backup_id: "backup-test".to_string(),
1482 discovery_topology_hash: Some(HASH.to_string()),
1483 pre_snapshot_topology_hash: Some(HASH.to_string()),
1484 artifacts: vec![ArtifactJournalEntry {
1485 canister_id: ROOT.to_string(),
1486 snapshot_id: "root-snapshot".to_string(),
1487 state: ArtifactState::Durable,
1488 temp_path: None,
1489 artifact_path: "artifacts/root".to_string(),
1490 checksum_algorithm: "sha256".to_string(),
1491 checksum: Some(checksum),
1492 updated_at: "2026-05-03T00:00:00Z".to_string(),
1493 }],
1494 }
1495 }
1496
1497 fn created_journal() -> DownloadJournal {
1499 DownloadJournal {
1500 journal_version: 1,
1501 backup_id: "backup-test".to_string(),
1502 discovery_topology_hash: Some(HASH.to_string()),
1503 pre_snapshot_topology_hash: Some(HASH.to_string()),
1504 artifacts: vec![ArtifactJournalEntry {
1505 canister_id: ROOT.to_string(),
1506 snapshot_id: "root-snapshot".to_string(),
1507 state: ArtifactState::Created,
1508 temp_path: None,
1509 artifact_path: "artifacts/root".to_string(),
1510 checksum_algorithm: "sha256".to_string(),
1511 checksum: None,
1512 updated_at: "2026-05-03T00:00:00Z".to_string(),
1513 }],
1514 }
1515 }
1516
1517 fn ready_inspection_report() -> BackupInspectionReport {
1519 BackupInspectionReport {
1520 backup_id: "backup-test".to_string(),
1521 manifest_backup_id: "backup-test".to_string(),
1522 journal_backup_id: "backup-test".to_string(),
1523 backup_id_matches: true,
1524 journal_complete: true,
1525 ready_for_verify: true,
1526 manifest_members: 1,
1527 journal_artifacts: 1,
1528 matched_artifacts: 1,
1529 topology_receipt_mismatches: Vec::new(),
1530 missing_journal_artifacts: Vec::new(),
1531 unexpected_journal_artifacts: Vec::new(),
1532 path_mismatches: Vec::new(),
1533 checksum_mismatches: Vec::new(),
1534 }
1535 }
1536
1537 fn ready_provenance_report() -> BackupProvenanceReport {
1539 BackupProvenanceReport {
1540 backup_id: "backup-test".to_string(),
1541 manifest_backup_id: "backup-test".to_string(),
1542 journal_backup_id: "backup-test".to_string(),
1543 backup_id_matches: true,
1544 manifest_version: 1,
1545 journal_version: 1,
1546 created_at: "2026-05-03T00:00:00Z".to_string(),
1547 tool_name: "canic".to_string(),
1548 tool_version: "0.30.12".to_string(),
1549 source_environment: "local".to_string(),
1550 source_root_canister: ROOT.to_string(),
1551 topology_hash_algorithm: "sha256".to_string(),
1552 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1553 discovery_topology_hash: HASH.to_string(),
1554 pre_snapshot_topology_hash: HASH.to_string(),
1555 accepted_topology_hash: HASH.to_string(),
1556 journal_discovery_topology_hash: Some(HASH.to_string()),
1557 journal_pre_snapshot_topology_hash: Some(HASH.to_string()),
1558 topology_receipts_match: true,
1559 topology_receipt_mismatches: Vec::new(),
1560 backup_unit_count: 1,
1561 member_count: 1,
1562 consistency_mode: "crash-consistent".to_string(),
1563 backup_units: Vec::new(),
1564 members: Vec::new(),
1565 }
1566 }
1567
1568 fn write_artifact(root: &Path, bytes: &[u8]) -> ArtifactChecksum {
1570 let path = root.join("artifacts/root");
1571 fs::create_dir_all(path.parent().expect("artifact has parent")).expect("create artifacts");
1572 fs::write(&path, bytes).expect("write artifact");
1573 ArtifactChecksum::from_bytes(bytes)
1574 }
1575
1576 fn temp_dir(prefix: &str) -> PathBuf {
1578 let nanos = SystemTime::now()
1579 .duration_since(UNIX_EPOCH)
1580 .expect("system time after epoch")
1581 .as_nanos();
1582 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
1583 }
1584}