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