Skip to main content

canic_cli/backup/
mod.rs

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///
20/// BackupCommandError
21///
22
23#[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///
85/// BackupPreflightOptions
86///
87
88#[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    /// Parse backup preflight options from CLI arguments.
97    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///
128/// BackupPreflightReport
129///
130
131#[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///
160/// BackupInspectOptions
161///
162
163#[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    /// Parse backup inspection options from CLI arguments.
172    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///
203/// BackupProvenanceOptions
204///
205
206#[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    /// Parse backup provenance options from CLI arguments.
215    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///
246/// BackupVerifyOptions
247///
248
249#[derive(Clone, Debug, Eq, PartialEq)]
250pub struct BackupVerifyOptions {
251    pub dir: PathBuf,
252    pub out: Option<PathBuf>,
253}
254
255impl BackupVerifyOptions {
256    /// Parse backup verification options from CLI arguments.
257    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///
285/// BackupStatusOptions
286///
287
288#[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    /// Parse backup status options from CLI arguments.
297    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
327/// Run a backup subcommand.
328pub 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
375/// Run all no-mutation backup checks and write standard preflight artifacts.
376pub 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
458/// Inspect manifest and journal agreement without reading artifact bytes.
459pub 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
466/// Report manifest and journal provenance without reading artifact bytes.
467pub 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
474// Ensure provenance is internally consistent when requested by scripts.
475fn 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
491// Ensure an inspection report is ready for full verification when requested.
492fn 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
513/// Summarize a backup journal's resumable state.
514pub 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
522// Ensure a journal status report has no remaining resume work.
523fn 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
535// Enforce caller-requested status requirements after the JSON report is written.
536fn 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
547/// Verify a backup directory's manifest, journal, and durable artifacts.
548pub 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
555// Write the journal status report to stdout or a requested output file.
556fn 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
573// Write the inspection report to stdout or a requested output file.
574fn 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
591// Write the provenance report to stdout or a requested output file.
592fn 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
609// Write the integrity report to stdout or a requested output file.
610fn 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
627// Write one pretty JSON value artifact, creating its parent directory when needed.
628fn 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
641// Build the compact preflight summary emitted after all checks pass.
642fn 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
671// Build the same compact validation summary emitted by manifest validation.
672fn 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
681// Return the stable summary status for inspection readiness.
682const fn readiness_status(ready: bool) -> &'static str {
683    if ready { "ready" } else { "not-ready" }
684}
685
686// Return the stable summary status for provenance consistency.
687const fn consistency_status(consistent: bool) -> &'static str {
688    if consistent {
689        "consistent"
690    } else {
691        "inconsistent"
692    }
693}
694
695// Return the stable summary status for equality checks.
696const fn match_status(matches: bool) -> &'static str {
697    if matches { "matched" } else { "mismatched" }
698}
699
700// Read and decode an optional source-to-target restore mapping from disk.
701fn 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
706// Read the next required option value.
707fn 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
716// Return backup command usage text.
717const 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    // Ensure backup preflight options parse the intended command shape.
743    #[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    // Ensure preflight writes the standard no-mutation report bundle.
761    #[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    // Ensure preflight stops on incomplete journals before claiming readiness.
845    #[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    // Ensure backup verification options parse the intended command shape.
879    #[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    // Ensure backup inspection options parse the intended command shape.
894    #[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    // Ensure backup provenance options parse the intended command shape.
911    #[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    // Ensure backup status options parse the intended command shape.
928    #[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    // Ensure backup status reads the journal and reports resume actions.
945    #[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    // Ensure backup inspection reports manifest and journal agreement.
969    #[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    // Ensure backup provenance reports manifest and journal audit metadata.
998    #[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    // Ensure require-consistent accepts matching provenance reports.
1033    #[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    // Ensure require-consistent rejects backup ID or topology receipt drift.
1047    #[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    // Ensure require-ready accepts inspection reports ready for verification.
1081    #[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    // Ensure require-ready rejects inspection reports with metadata drift.
1094    #[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    // Ensure require-ready rejects topology receipt drift.
1125    #[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    // Ensure require-complete accepts already durable backup journals.
1156    #[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    // Ensure require-complete rejects journals that still need resume work.
1169    #[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    // Ensure the CLI verification path reads a layout and returns an integrity report.
1192    #[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    // Build one valid manifest for CLI verification tests.
1219    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    // Build one valid manifest member.
1257    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    // Build one durable journal with a caller-provided checksum.
1285    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    // Build one incomplete journal that still needs artifact download work.
1305    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    // Build one ready inspection report for requirement tests.
1325    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    // Build one matching provenance report for requirement tests.
1345    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    // Write one artifact at the layout-relative path used by test journals.
1376    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    // Build a unique temporary directory.
1384    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}