1use canic_backup::{
2 journal::JournalResumeReport,
3 manifest::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 "topology_hash": manifest.fleet.topology_hash,
678 })
679}
680
681const fn readiness_status(ready: bool) -> &'static str {
683 if ready { "ready" } else { "not-ready" }
684}
685
686const fn consistency_status(consistent: bool) -> &'static str {
688 if consistent {
689 "consistent"
690 } else {
691 "inconsistent"
692 }
693}
694
695const fn match_status(matches: bool) -> &'static str {
697 if matches { "matched" } else { "mismatched" }
698}
699
700fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, BackupCommandError> {
702 let data = fs::read_to_string(path)?;
703 serde_json::from_str(&data).map_err(BackupCommandError::from)
704}
705
706fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, BackupCommandError>
708where
709 I: Iterator<Item = OsString>,
710{
711 args.next()
712 .and_then(|value| value.into_string().ok())
713 .ok_or(BackupCommandError::MissingValue(option))
714}
715
716const fn usage() -> &'static str {
718 "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>]"
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724 use canic_backup::{
725 artifacts::ArtifactChecksum,
726 journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
727 manifest::{
728 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetBackupManifest,
729 FleetMember, FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
730 VerificationCheck, VerificationPlan,
731 },
732 };
733 use std::{
734 fs,
735 path::Path,
736 time::{SystemTime, UNIX_EPOCH},
737 };
738
739 const ROOT: &str = "aaaaa-aa";
740 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
741
742 #[test]
744 fn parses_backup_preflight_options() {
745 let options = BackupPreflightOptions::parse([
746 OsString::from("--dir"),
747 OsString::from("backups/run"),
748 OsString::from("--out-dir"),
749 OsString::from("reports/run"),
750 OsString::from("--mapping"),
751 OsString::from("mapping.json"),
752 ])
753 .expect("parse options");
754
755 assert_eq!(options.dir, PathBuf::from("backups/run"));
756 assert_eq!(options.out_dir, PathBuf::from("reports/run"));
757 assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
758 }
759
760 #[test]
762 fn backup_preflight_writes_standard_reports() {
763 let root = temp_dir("canic-cli-backup-preflight");
764 let out_dir = root.join("reports");
765 let backup_dir = root.join("backup");
766 let layout = BackupLayout::new(backup_dir.clone());
767 let checksum = write_artifact(&backup_dir, b"root artifact");
768
769 layout
770 .write_manifest(&valid_manifest())
771 .expect("write manifest");
772 layout
773 .write_journal(&journal_with_checksum(checksum.hash))
774 .expect("write journal");
775
776 let options = BackupPreflightOptions {
777 dir: backup_dir,
778 out_dir: out_dir.clone(),
779 mapping: None,
780 };
781 let report = backup_preflight(&options).expect("run preflight");
782
783 assert_eq!(report.status, "ready");
784 assert_eq!(report.backup_id, "backup-test");
785 assert_eq!(report.source_environment, "local");
786 assert_eq!(report.source_root_canister, ROOT);
787 assert_eq!(report.topology_hash, HASH);
788 assert_eq!(report.mapping_path, None);
789 assert!(report.journal_complete);
790 assert_eq!(report.inspection_status, "ready");
791 assert_eq!(report.provenance_status, "consistent");
792 assert_eq!(report.backup_id_status, "matched");
793 assert_eq!(report.topology_receipts_status, "matched");
794 assert_eq!(report.topology_mismatch_count, 0);
795 assert!(report.integrity_verified);
796 assert_eq!(report.manifest_members, 1);
797 assert_eq!(report.backup_unit_count, 1);
798 assert_eq!(report.restore_plan_members, 1);
799 assert!(out_dir.join("manifest-validation.json").exists());
800 assert!(out_dir.join("backup-status.json").exists());
801 assert!(out_dir.join("backup-inspection.json").exists());
802 assert!(out_dir.join("backup-provenance.json").exists());
803 assert!(out_dir.join("backup-integrity.json").exists());
804 assert!(out_dir.join("restore-plan.json").exists());
805 assert!(out_dir.join("preflight-summary.json").exists());
806
807 let summary: serde_json::Value = serde_json::from_slice(
808 &fs::read(out_dir.join("preflight-summary.json")).expect("read summary"),
809 )
810 .expect("decode summary");
811
812 fs::remove_dir_all(root).expect("remove temp root");
813 assert_eq!(summary["status"], report.status);
814 assert_eq!(summary["backup_id"], report.backup_id);
815 assert_eq!(summary["source_environment"], report.source_environment);
816 assert_eq!(summary["source_root_canister"], report.source_root_canister);
817 assert_eq!(summary["topology_hash"], report.topology_hash);
818 assert_eq!(summary["journal_complete"], report.journal_complete);
819 assert_eq!(summary["inspection_status"], report.inspection_status);
820 assert_eq!(summary["provenance_status"], report.provenance_status);
821 assert_eq!(summary["backup_id_status"], report.backup_id_status);
822 assert_eq!(
823 summary["topology_receipts_status"],
824 report.topology_receipts_status
825 );
826 assert_eq!(
827 summary["topology_mismatch_count"],
828 report.topology_mismatch_count
829 );
830 assert_eq!(summary["integrity_verified"], report.integrity_verified);
831 assert_eq!(summary["manifest_members"], report.manifest_members);
832 assert_eq!(summary["backup_unit_count"], report.backup_unit_count);
833 assert_eq!(summary["restore_plan_members"], report.restore_plan_members);
834 assert_eq!(
835 summary["backup_inspection_path"],
836 report.backup_inspection_path
837 );
838 assert_eq!(
839 summary["backup_provenance_path"],
840 report.backup_provenance_path
841 );
842 }
843
844 #[test]
846 fn backup_preflight_rejects_incomplete_journal() {
847 let root = temp_dir("canic-cli-backup-preflight-incomplete");
848 let out_dir = root.join("reports");
849 let backup_dir = root.join("backup");
850 let layout = BackupLayout::new(backup_dir.clone());
851
852 layout
853 .write_manifest(&valid_manifest())
854 .expect("write manifest");
855 layout
856 .write_journal(&created_journal())
857 .expect("write journal");
858
859 let options = BackupPreflightOptions {
860 dir: backup_dir,
861 out_dir,
862 mapping: None,
863 };
864
865 let err = backup_preflight(&options).expect_err("incomplete journal should fail");
866
867 fs::remove_dir_all(root).expect("remove temp root");
868 assert!(matches!(
869 err,
870 BackupCommandError::IncompleteJournal {
871 pending_artifacts: 1,
872 total_artifacts: 1,
873 ..
874 }
875 ));
876 }
877
878 #[test]
880 fn parses_backup_verify_options() {
881 let options = BackupVerifyOptions::parse([
882 OsString::from("--dir"),
883 OsString::from("backups/run"),
884 OsString::from("--out"),
885 OsString::from("report.json"),
886 ])
887 .expect("parse options");
888
889 assert_eq!(options.dir, PathBuf::from("backups/run"));
890 assert_eq!(options.out, Some(PathBuf::from("report.json")));
891 }
892
893 #[test]
895 fn parses_backup_inspect_options() {
896 let options = BackupInspectOptions::parse([
897 OsString::from("--dir"),
898 OsString::from("backups/run"),
899 OsString::from("--out"),
900 OsString::from("inspect.json"),
901 OsString::from("--require-ready"),
902 ])
903 .expect("parse options");
904
905 assert_eq!(options.dir, PathBuf::from("backups/run"));
906 assert_eq!(options.out, Some(PathBuf::from("inspect.json")));
907 assert!(options.require_ready);
908 }
909
910 #[test]
912 fn parses_backup_provenance_options() {
913 let options = BackupProvenanceOptions::parse([
914 OsString::from("--dir"),
915 OsString::from("backups/run"),
916 OsString::from("--out"),
917 OsString::from("provenance.json"),
918 OsString::from("--require-consistent"),
919 ])
920 .expect("parse options");
921
922 assert_eq!(options.dir, PathBuf::from("backups/run"));
923 assert_eq!(options.out, Some(PathBuf::from("provenance.json")));
924 assert!(options.require_consistent);
925 }
926
927 #[test]
929 fn parses_backup_status_options() {
930 let options = BackupStatusOptions::parse([
931 OsString::from("--dir"),
932 OsString::from("backups/run"),
933 OsString::from("--out"),
934 OsString::from("status.json"),
935 OsString::from("--require-complete"),
936 ])
937 .expect("parse options");
938
939 assert_eq!(options.dir, PathBuf::from("backups/run"));
940 assert_eq!(options.out, Some(PathBuf::from("status.json")));
941 assert!(options.require_complete);
942 }
943
944 #[test]
946 fn backup_status_reads_journal_resume_report() {
947 let root = temp_dir("canic-cli-backup-status");
948 let layout = BackupLayout::new(root.clone());
949 layout
950 .write_journal(&journal_with_checksum(HASH.to_string()))
951 .expect("write journal");
952
953 let options = BackupStatusOptions {
954 dir: root.clone(),
955 out: None,
956 require_complete: false,
957 };
958 let report = backup_status(&options).expect("read backup status");
959
960 fs::remove_dir_all(root).expect("remove temp root");
961 assert_eq!(report.backup_id, "backup-test");
962 assert_eq!(report.total_artifacts, 1);
963 assert!(report.is_complete);
964 assert_eq!(report.pending_artifacts, 0);
965 assert_eq!(report.counts.skip, 1);
966 }
967
968 #[test]
970 fn inspect_backup_reads_layout_metadata() {
971 let root = temp_dir("canic-cli-backup-inspect");
972 let layout = BackupLayout::new(root.clone());
973
974 layout
975 .write_manifest(&valid_manifest())
976 .expect("write manifest");
977 layout
978 .write_journal(&journal_with_checksum(HASH.to_string()))
979 .expect("write journal");
980
981 let options = BackupInspectOptions {
982 dir: root.clone(),
983 out: None,
984 require_ready: false,
985 };
986 let report = inspect_backup(&options).expect("inspect backup");
987
988 fs::remove_dir_all(root).expect("remove temp root");
989 assert_eq!(report.backup_id, "backup-test");
990 assert!(report.backup_id_matches);
991 assert!(report.journal_complete);
992 assert!(report.ready_for_verify);
993 assert!(report.topology_receipt_mismatches.is_empty());
994 assert_eq!(report.matched_artifacts, 1);
995 }
996
997 #[test]
999 fn backup_provenance_reads_layout_metadata() {
1000 let root = temp_dir("canic-cli-backup-provenance");
1001 let layout = BackupLayout::new(root.clone());
1002
1003 layout
1004 .write_manifest(&valid_manifest())
1005 .expect("write manifest");
1006 layout
1007 .write_journal(&journal_with_checksum(HASH.to_string()))
1008 .expect("write journal");
1009
1010 let options = BackupProvenanceOptions {
1011 dir: root.clone(),
1012 out: None,
1013 require_consistent: false,
1014 };
1015 let report = backup_provenance(&options).expect("read provenance");
1016
1017 fs::remove_dir_all(root).expect("remove temp root");
1018 assert_eq!(report.backup_id, "backup-test");
1019 assert!(report.backup_id_matches);
1020 assert_eq!(report.source_environment, "local");
1021 assert_eq!(report.discovery_topology_hash, HASH);
1022 assert!(report.topology_receipts_match);
1023 assert!(report.topology_receipt_mismatches.is_empty());
1024 assert_eq!(report.backup_unit_count, 1);
1025 assert_eq!(report.member_count, 1);
1026 assert_eq!(report.backup_units[0].kind, "subtree-rooted");
1027 assert_eq!(report.members[0].canister_id, ROOT);
1028 assert_eq!(report.members[0].snapshot_id, "root-snapshot");
1029 assert_eq!(report.members[0].journal_state, Some("Durable".to_string()));
1030 }
1031
1032 #[test]
1034 fn require_consistent_accepts_matching_provenance() {
1035 let options = BackupProvenanceOptions {
1036 dir: PathBuf::from("unused"),
1037 out: None,
1038 require_consistent: true,
1039 };
1040 let report = ready_provenance_report();
1041
1042 enforce_provenance_requirements(&options, &report)
1043 .expect("matching provenance should pass");
1044 }
1045
1046 #[test]
1048 fn require_consistent_rejects_provenance_drift() {
1049 let options = BackupProvenanceOptions {
1050 dir: PathBuf::from("unused"),
1051 out: None,
1052 require_consistent: true,
1053 };
1054 let mut report = ready_provenance_report();
1055 report.backup_id_matches = false;
1056 report.journal_backup_id = "other-backup".to_string();
1057 report.topology_receipts_match = false;
1058 report.topology_receipt_mismatches.push(
1059 canic_backup::persistence::TopologyReceiptMismatch {
1060 field: "pre_snapshot_topology_hash".to_string(),
1061 manifest: HASH.to_string(),
1062 journal: None,
1063 },
1064 );
1065
1066 let err = enforce_provenance_requirements(&options, &report)
1067 .expect_err("provenance drift should fail");
1068
1069 assert!(matches!(
1070 err,
1071 BackupCommandError::ProvenanceNotConsistent {
1072 backup_id_matches: false,
1073 topology_receipts_match: false,
1074 topology_mismatches: 1,
1075 ..
1076 }
1077 ));
1078 }
1079
1080 #[test]
1082 fn require_ready_accepts_ready_inspection() {
1083 let options = BackupInspectOptions {
1084 dir: PathBuf::from("unused"),
1085 out: None,
1086 require_ready: true,
1087 };
1088 let report = ready_inspection_report();
1089
1090 enforce_inspection_requirements(&options, &report).expect("ready inspection should pass");
1091 }
1092
1093 #[test]
1095 fn require_ready_rejects_unready_inspection() {
1096 let options = BackupInspectOptions {
1097 dir: PathBuf::from("unused"),
1098 out: None,
1099 require_ready: true,
1100 };
1101 let mut report = ready_inspection_report();
1102 report.ready_for_verify = false;
1103 report
1104 .path_mismatches
1105 .push(canic_backup::persistence::ArtifactPathMismatch {
1106 canister_id: ROOT.to_string(),
1107 snapshot_id: "root-snapshot".to_string(),
1108 manifest: "artifacts/root".to_string(),
1109 journal: "artifacts/other-root".to_string(),
1110 });
1111
1112 let err = enforce_inspection_requirements(&options, &report)
1113 .expect_err("unready inspection should fail");
1114
1115 assert!(matches!(
1116 err,
1117 BackupCommandError::InspectionNotReady {
1118 path_mismatches: 1,
1119 ..
1120 }
1121 ));
1122 }
1123
1124 #[test]
1126 fn require_ready_rejects_topology_receipt_drift() {
1127 let options = BackupInspectOptions {
1128 dir: PathBuf::from("unused"),
1129 out: None,
1130 require_ready: true,
1131 };
1132 let mut report = ready_inspection_report();
1133 report.ready_for_verify = false;
1134 report.topology_receipt_mismatches.push(
1135 canic_backup::persistence::TopologyReceiptMismatch {
1136 field: "discovery_topology_hash".to_string(),
1137 manifest: HASH.to_string(),
1138 journal: None,
1139 },
1140 );
1141
1142 let err = enforce_inspection_requirements(&options, &report)
1143 .expect_err("topology receipt drift should fail");
1144
1145 assert!(matches!(
1146 err,
1147 BackupCommandError::InspectionNotReady {
1148 topology_receipts_match: false,
1149 topology_mismatches: 1,
1150 ..
1151 }
1152 ));
1153 }
1154
1155 #[test]
1157 fn require_complete_accepts_complete_status() {
1158 let options = BackupStatusOptions {
1159 dir: PathBuf::from("unused"),
1160 out: None,
1161 require_complete: true,
1162 };
1163 let report = journal_with_checksum(HASH.to_string()).resume_report();
1164
1165 enforce_status_requirements(&options, &report).expect("complete status should pass");
1166 }
1167
1168 #[test]
1170 fn require_complete_rejects_incomplete_status() {
1171 let options = BackupStatusOptions {
1172 dir: PathBuf::from("unused"),
1173 out: None,
1174 require_complete: true,
1175 };
1176 let report = created_journal().resume_report();
1177
1178 let err = enforce_status_requirements(&options, &report)
1179 .expect_err("incomplete status should fail");
1180
1181 assert!(matches!(
1182 err,
1183 BackupCommandError::IncompleteJournal {
1184 pending_artifacts: 1,
1185 total_artifacts: 1,
1186 ..
1187 }
1188 ));
1189 }
1190
1191 #[test]
1193 fn verify_backup_reads_layout_and_artifacts() {
1194 let root = temp_dir("canic-cli-backup-verify");
1195 let layout = BackupLayout::new(root.clone());
1196 let checksum = write_artifact(&root, b"root artifact");
1197
1198 layout
1199 .write_manifest(&valid_manifest())
1200 .expect("write manifest");
1201 layout
1202 .write_journal(&journal_with_checksum(checksum.hash.clone()))
1203 .expect("write journal");
1204
1205 let options = BackupVerifyOptions {
1206 dir: root.clone(),
1207 out: None,
1208 };
1209 let report = verify_backup(&options).expect("verify backup");
1210
1211 fs::remove_dir_all(root).expect("remove temp root");
1212 assert_eq!(report.backup_id, "backup-test");
1213 assert!(report.verified);
1214 assert_eq!(report.durable_artifacts, 1);
1215 assert_eq!(report.artifacts[0].checksum, checksum.hash);
1216 }
1217
1218 fn valid_manifest() -> FleetBackupManifest {
1220 FleetBackupManifest {
1221 manifest_version: 1,
1222 backup_id: "backup-test".to_string(),
1223 created_at: "2026-05-03T00:00:00Z".to_string(),
1224 tool: ToolMetadata {
1225 name: "canic".to_string(),
1226 version: "0.30.3".to_string(),
1227 },
1228 source: SourceMetadata {
1229 environment: "local".to_string(),
1230 root_canister: ROOT.to_string(),
1231 },
1232 consistency: ConsistencySection {
1233 mode: ConsistencyMode::CrashConsistent,
1234 backup_units: vec![BackupUnit {
1235 unit_id: "fleet".to_string(),
1236 kind: BackupUnitKind::SubtreeRooted,
1237 roles: vec!["root".to_string()],
1238 consistency_reason: None,
1239 dependency_closure: Vec::new(),
1240 topology_validation: "subtree-closed".to_string(),
1241 quiescence_strategy: None,
1242 }],
1243 },
1244 fleet: FleetSection {
1245 topology_hash_algorithm: "sha256".to_string(),
1246 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1247 discovery_topology_hash: HASH.to_string(),
1248 pre_snapshot_topology_hash: HASH.to_string(),
1249 topology_hash: HASH.to_string(),
1250 members: vec![fleet_member()],
1251 },
1252 verification: VerificationPlan::default(),
1253 }
1254 }
1255
1256 fn fleet_member() -> FleetMember {
1258 FleetMember {
1259 role: "root".to_string(),
1260 canister_id: ROOT.to_string(),
1261 parent_canister_id: None,
1262 subnet_canister_id: Some(ROOT.to_string()),
1263 controller_hint: None,
1264 identity_mode: IdentityMode::Fixed,
1265 restore_group: 1,
1266 verification_class: "basic".to_string(),
1267 verification_checks: vec![VerificationCheck {
1268 kind: "status".to_string(),
1269 method: None,
1270 roles: vec!["root".to_string()],
1271 }],
1272 source_snapshot: SourceSnapshot {
1273 snapshot_id: "root-snapshot".to_string(),
1274 module_hash: None,
1275 wasm_hash: None,
1276 code_version: Some("v0.30.3".to_string()),
1277 artifact_path: "artifacts/root".to_string(),
1278 checksum_algorithm: "sha256".to_string(),
1279 checksum: None,
1280 },
1281 }
1282 }
1283
1284 fn journal_with_checksum(checksum: String) -> DownloadJournal {
1286 DownloadJournal {
1287 journal_version: 1,
1288 backup_id: "backup-test".to_string(),
1289 discovery_topology_hash: Some(HASH.to_string()),
1290 pre_snapshot_topology_hash: Some(HASH.to_string()),
1291 artifacts: vec![ArtifactJournalEntry {
1292 canister_id: ROOT.to_string(),
1293 snapshot_id: "root-snapshot".to_string(),
1294 state: ArtifactState::Durable,
1295 temp_path: None,
1296 artifact_path: "artifacts/root".to_string(),
1297 checksum_algorithm: "sha256".to_string(),
1298 checksum: Some(checksum),
1299 updated_at: "2026-05-03T00:00:00Z".to_string(),
1300 }],
1301 }
1302 }
1303
1304 fn created_journal() -> DownloadJournal {
1306 DownloadJournal {
1307 journal_version: 1,
1308 backup_id: "backup-test".to_string(),
1309 discovery_topology_hash: Some(HASH.to_string()),
1310 pre_snapshot_topology_hash: Some(HASH.to_string()),
1311 artifacts: vec![ArtifactJournalEntry {
1312 canister_id: ROOT.to_string(),
1313 snapshot_id: "root-snapshot".to_string(),
1314 state: ArtifactState::Created,
1315 temp_path: None,
1316 artifact_path: "artifacts/root".to_string(),
1317 checksum_algorithm: "sha256".to_string(),
1318 checksum: None,
1319 updated_at: "2026-05-03T00:00:00Z".to_string(),
1320 }],
1321 }
1322 }
1323
1324 fn ready_inspection_report() -> BackupInspectionReport {
1326 BackupInspectionReport {
1327 backup_id: "backup-test".to_string(),
1328 manifest_backup_id: "backup-test".to_string(),
1329 journal_backup_id: "backup-test".to_string(),
1330 backup_id_matches: true,
1331 journal_complete: true,
1332 ready_for_verify: true,
1333 manifest_members: 1,
1334 journal_artifacts: 1,
1335 matched_artifacts: 1,
1336 topology_receipt_mismatches: Vec::new(),
1337 missing_journal_artifacts: Vec::new(),
1338 unexpected_journal_artifacts: Vec::new(),
1339 path_mismatches: Vec::new(),
1340 checksum_mismatches: Vec::new(),
1341 }
1342 }
1343
1344 fn ready_provenance_report() -> BackupProvenanceReport {
1346 BackupProvenanceReport {
1347 backup_id: "backup-test".to_string(),
1348 manifest_backup_id: "backup-test".to_string(),
1349 journal_backup_id: "backup-test".to_string(),
1350 backup_id_matches: true,
1351 manifest_version: 1,
1352 journal_version: 1,
1353 created_at: "2026-05-03T00:00:00Z".to_string(),
1354 tool_name: "canic".to_string(),
1355 tool_version: "0.30.12".to_string(),
1356 source_environment: "local".to_string(),
1357 source_root_canister: ROOT.to_string(),
1358 topology_hash_algorithm: "sha256".to_string(),
1359 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1360 discovery_topology_hash: HASH.to_string(),
1361 pre_snapshot_topology_hash: HASH.to_string(),
1362 accepted_topology_hash: HASH.to_string(),
1363 journal_discovery_topology_hash: Some(HASH.to_string()),
1364 journal_pre_snapshot_topology_hash: Some(HASH.to_string()),
1365 topology_receipts_match: true,
1366 topology_receipt_mismatches: Vec::new(),
1367 backup_unit_count: 1,
1368 member_count: 1,
1369 consistency_mode: "crash-consistent".to_string(),
1370 backup_units: Vec::new(),
1371 members: Vec::new(),
1372 }
1373 }
1374
1375 fn write_artifact(root: &Path, bytes: &[u8]) -> ArtifactChecksum {
1377 let path = root.join("artifacts/root");
1378 fs::create_dir_all(path.parent().expect("artifact has parent")).expect("create artifacts");
1379 fs::write(&path, bytes).expect("write artifact");
1380 ArtifactChecksum::from_bytes(bytes)
1381 }
1382
1383 fn temp_dir(prefix: &str) -> PathBuf {
1385 let nanos = SystemTime::now()
1386 .duration_since(UNIX_EPOCH)
1387 .expect("system time after epoch")
1388 .as_nanos();
1389 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
1390 }
1391}