Skip to main content

canic_cli/backup/
mod.rs

1use canic_backup::{
2    journal::JournalResumeReport,
3    manifest::FleetBackupManifest,
4    persistence::{BackupIntegrityReport, BackupLayout, PersistenceError},
5    restore::{RestoreMapping, RestorePlanError, RestorePlanner},
6};
7use serde_json::json;
8use std::{
9    ffi::OsString,
10    fs,
11    io::{self, Write},
12    path::PathBuf,
13};
14use thiserror::Error as ThisError;
15
16///
17/// BackupCommandError
18///
19
20#[derive(Debug, ThisError)]
21pub enum BackupCommandError {
22    #[error("{0}")]
23    Usage(&'static str),
24
25    #[error("missing required option {0}")]
26    MissingOption(&'static str),
27
28    #[error("unknown option {0}")]
29    UnknownOption(String),
30
31    #[error("option {0} requires a value")]
32    MissingValue(&'static str),
33
34    #[error(
35        "backup journal {backup_id} is incomplete: {pending_artifacts}/{total_artifacts} artifacts still require resume work"
36    )]
37    IncompleteJournal {
38        backup_id: String,
39        total_artifacts: usize,
40        pending_artifacts: usize,
41    },
42
43    #[error(transparent)]
44    Io(#[from] std::io::Error),
45
46    #[error(transparent)]
47    Json(#[from] serde_json::Error),
48
49    #[error(transparent)]
50    Persistence(#[from] PersistenceError),
51
52    #[error(transparent)]
53    RestorePlan(#[from] RestorePlanError),
54}
55
56///
57/// BackupPreflightOptions
58///
59
60#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct BackupPreflightOptions {
62    pub dir: PathBuf,
63    pub out_dir: PathBuf,
64    pub mapping: Option<PathBuf>,
65}
66
67impl BackupPreflightOptions {
68    /// Parse backup preflight options from CLI arguments.
69    pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
70    where
71        I: IntoIterator<Item = OsString>,
72    {
73        let mut dir = None;
74        let mut out_dir = None;
75        let mut mapping = None;
76
77        let mut args = args.into_iter();
78        while let Some(arg) = args.next() {
79            let arg = arg
80                .into_string()
81                .map_err(|_| BackupCommandError::Usage(usage()))?;
82            match arg.as_str() {
83                "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
84                "--out-dir" => out_dir = Some(PathBuf::from(next_value(&mut args, "--out-dir")?)),
85                "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
86                "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
87                _ => return Err(BackupCommandError::UnknownOption(arg)),
88            }
89        }
90
91        Ok(Self {
92            dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
93            out_dir: out_dir.ok_or(BackupCommandError::MissingOption("--out-dir"))?,
94            mapping,
95        })
96    }
97}
98
99///
100/// BackupPreflightReport
101///
102
103#[derive(Clone, Debug, Eq, PartialEq)]
104pub struct BackupPreflightReport {
105    pub status: String,
106    pub backup_id: String,
107    pub backup_dir: String,
108    pub source_environment: String,
109    pub source_root_canister: String,
110    pub topology_hash: String,
111    pub mapping_path: Option<String>,
112    pub journal_complete: bool,
113    pub integrity_verified: bool,
114    pub manifest_members: usize,
115    pub restore_plan_members: usize,
116    pub manifest_validation_path: String,
117    pub backup_status_path: String,
118    pub backup_integrity_path: String,
119    pub restore_plan_path: String,
120    pub preflight_summary_path: String,
121}
122
123///
124/// BackupVerifyOptions
125///
126
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct BackupVerifyOptions {
129    pub dir: PathBuf,
130    pub out: Option<PathBuf>,
131}
132
133impl BackupVerifyOptions {
134    /// Parse backup verification options from CLI arguments.
135    pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
136    where
137        I: IntoIterator<Item = OsString>,
138    {
139        let mut dir = None;
140        let mut out = None;
141
142        let mut args = args.into_iter();
143        while let Some(arg) = args.next() {
144            let arg = arg
145                .into_string()
146                .map_err(|_| BackupCommandError::Usage(usage()))?;
147            match arg.as_str() {
148                "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
149                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
150                "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
151                _ => return Err(BackupCommandError::UnknownOption(arg)),
152            }
153        }
154
155        Ok(Self {
156            dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
157            out,
158        })
159    }
160}
161
162///
163/// BackupStatusOptions
164///
165
166#[derive(Clone, Debug, Eq, PartialEq)]
167pub struct BackupStatusOptions {
168    pub dir: PathBuf,
169    pub out: Option<PathBuf>,
170    pub require_complete: bool,
171}
172
173impl BackupStatusOptions {
174    /// Parse backup status options from CLI arguments.
175    pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
176    where
177        I: IntoIterator<Item = OsString>,
178    {
179        let mut dir = None;
180        let mut out = None;
181        let mut require_complete = false;
182
183        let mut args = args.into_iter();
184        while let Some(arg) = args.next() {
185            let arg = arg
186                .into_string()
187                .map_err(|_| BackupCommandError::Usage(usage()))?;
188            match arg.as_str() {
189                "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
190                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
191                "--require-complete" => require_complete = true,
192                "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
193                _ => return Err(BackupCommandError::UnknownOption(arg)),
194            }
195        }
196
197        Ok(Self {
198            dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
199            out,
200            require_complete,
201        })
202    }
203}
204
205/// Run a backup subcommand.
206pub fn run<I>(args: I) -> Result<(), BackupCommandError>
207where
208    I: IntoIterator<Item = OsString>,
209{
210    let mut args = args.into_iter();
211    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
212        return Err(BackupCommandError::Usage(usage()));
213    };
214
215    match command.as_str() {
216        "preflight" => {
217            let options = BackupPreflightOptions::parse(args)?;
218            backup_preflight(&options)?;
219            Ok(())
220        }
221        "status" => {
222            let options = BackupStatusOptions::parse(args)?;
223            let report = backup_status(&options)?;
224            write_status_report(&options, &report)?;
225            enforce_status_requirements(&options, &report)?;
226            Ok(())
227        }
228        "verify" => {
229            let options = BackupVerifyOptions::parse(args)?;
230            let report = verify_backup(&options)?;
231            write_report(&options, &report)?;
232            Ok(())
233        }
234        "help" | "--help" | "-h" => Err(BackupCommandError::Usage(usage())),
235        _ => Err(BackupCommandError::UnknownOption(command)),
236    }
237}
238
239/// Run all no-mutation backup checks and write standard preflight artifacts.
240pub fn backup_preflight(
241    options: &BackupPreflightOptions,
242) -> Result<BackupPreflightReport, BackupCommandError> {
243    fs::create_dir_all(&options.out_dir)?;
244
245    let layout = BackupLayout::new(options.dir.clone());
246    let manifest = layout.read_manifest()?;
247    let status = layout.read_journal()?.resume_report();
248    ensure_complete_status(&status)?;
249    let integrity = layout.verify_integrity()?;
250    let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
251    let restore_plan = RestorePlanner::plan(&manifest, mapping.as_ref())?;
252
253    let manifest_validation_path = options.out_dir.join("manifest-validation.json");
254    let backup_status_path = options.out_dir.join("backup-status.json");
255    let backup_integrity_path = options.out_dir.join("backup-integrity.json");
256    let restore_plan_path = options.out_dir.join("restore-plan.json");
257    let preflight_summary_path = options.out_dir.join("preflight-summary.json");
258
259    write_json_value_file(
260        &manifest_validation_path,
261        &manifest_validation_summary(&manifest),
262    )?;
263    fs::write(&backup_status_path, serde_json::to_vec_pretty(&status)?)?;
264    fs::write(
265        &backup_integrity_path,
266        serde_json::to_vec_pretty(&integrity)?,
267    )?;
268    fs::write(
269        &restore_plan_path,
270        serde_json::to_vec_pretty(&restore_plan)?,
271    )?;
272
273    let report = BackupPreflightReport {
274        status: "ready".to_string(),
275        backup_id: manifest.backup_id.clone(),
276        backup_dir: options.dir.display().to_string(),
277        source_environment: manifest.source.environment.clone(),
278        source_root_canister: manifest.source.root_canister.clone(),
279        topology_hash: manifest.fleet.topology_hash.clone(),
280        mapping_path: options
281            .mapping
282            .as_ref()
283            .map(|path| path.display().to_string()),
284        journal_complete: status.is_complete,
285        integrity_verified: integrity.verified,
286        manifest_members: manifest.fleet.members.len(),
287        restore_plan_members: restore_plan.member_count,
288        manifest_validation_path: manifest_validation_path.display().to_string(),
289        backup_status_path: backup_status_path.display().to_string(),
290        backup_integrity_path: backup_integrity_path.display().to_string(),
291        restore_plan_path: restore_plan_path.display().to_string(),
292        preflight_summary_path: preflight_summary_path.display().to_string(),
293    };
294
295    write_json_value_file(&preflight_summary_path, &preflight_summary_value(&report))?;
296    Ok(report)
297}
298
299/// Summarize a backup journal's resumable state.
300pub fn backup_status(
301    options: &BackupStatusOptions,
302) -> Result<JournalResumeReport, BackupCommandError> {
303    let layout = BackupLayout::new(options.dir.clone());
304    let journal = layout.read_journal()?;
305    Ok(journal.resume_report())
306}
307
308// Ensure a journal status report has no remaining resume work.
309fn ensure_complete_status(report: &JournalResumeReport) -> Result<(), BackupCommandError> {
310    if report.is_complete {
311        return Ok(());
312    }
313
314    Err(BackupCommandError::IncompleteJournal {
315        backup_id: report.backup_id.clone(),
316        total_artifacts: report.total_artifacts,
317        pending_artifacts: report.pending_artifacts,
318    })
319}
320
321// Enforce caller-requested status requirements after the JSON report is written.
322fn enforce_status_requirements(
323    options: &BackupStatusOptions,
324    report: &JournalResumeReport,
325) -> Result<(), BackupCommandError> {
326    if !options.require_complete {
327        return Ok(());
328    }
329
330    ensure_complete_status(report)
331}
332
333/// Verify a backup directory's manifest, journal, and durable artifacts.
334pub fn verify_backup(
335    options: &BackupVerifyOptions,
336) -> Result<BackupIntegrityReport, BackupCommandError> {
337    let layout = BackupLayout::new(options.dir.clone());
338    layout.verify_integrity().map_err(BackupCommandError::from)
339}
340
341// Write the journal status report to stdout or a requested output file.
342fn write_status_report(
343    options: &BackupStatusOptions,
344    report: &JournalResumeReport,
345) -> Result<(), BackupCommandError> {
346    if let Some(path) = &options.out {
347        let data = serde_json::to_vec_pretty(report)?;
348        fs::write(path, data)?;
349        return Ok(());
350    }
351
352    let stdout = io::stdout();
353    let mut handle = stdout.lock();
354    serde_json::to_writer_pretty(&mut handle, report)?;
355    writeln!(handle)?;
356    Ok(())
357}
358
359// Write the integrity report to stdout or a requested output file.
360fn write_report(
361    options: &BackupVerifyOptions,
362    report: &BackupIntegrityReport,
363) -> Result<(), BackupCommandError> {
364    if let Some(path) = &options.out {
365        let data = serde_json::to_vec_pretty(report)?;
366        fs::write(path, data)?;
367        return Ok(());
368    }
369
370    let stdout = io::stdout();
371    let mut handle = stdout.lock();
372    serde_json::to_writer_pretty(&mut handle, report)?;
373    writeln!(handle)?;
374    Ok(())
375}
376
377// Write one pretty JSON value artifact, creating its parent directory when needed.
378fn write_json_value_file(
379    path: &PathBuf,
380    value: &serde_json::Value,
381) -> Result<(), BackupCommandError> {
382    if let Some(parent) = path.parent() {
383        fs::create_dir_all(parent)?;
384    }
385
386    let data = serde_json::to_vec_pretty(value)?;
387    fs::write(path, data)?;
388    Ok(())
389}
390
391// Build the compact preflight summary emitted after all checks pass.
392fn preflight_summary_value(report: &BackupPreflightReport) -> serde_json::Value {
393    json!({
394        "status": report.status,
395        "backup_id": report.backup_id,
396        "backup_dir": report.backup_dir,
397        "source_environment": report.source_environment,
398        "source_root_canister": report.source_root_canister,
399        "topology_hash": report.topology_hash,
400        "mapping_path": report.mapping_path,
401        "journal_complete": report.journal_complete,
402        "integrity_verified": report.integrity_verified,
403        "manifest_members": report.manifest_members,
404        "restore_plan_members": report.restore_plan_members,
405        "manifest_validation_path": report.manifest_validation_path,
406        "backup_status_path": report.backup_status_path,
407        "backup_integrity_path": report.backup_integrity_path,
408        "restore_plan_path": report.restore_plan_path,
409        "preflight_summary_path": report.preflight_summary_path,
410    })
411}
412
413// Build the same compact validation summary emitted by manifest validation.
414fn manifest_validation_summary(manifest: &FleetBackupManifest) -> serde_json::Value {
415    json!({
416        "status": "valid",
417        "backup_id": manifest.backup_id,
418        "members": manifest.fleet.members.len(),
419        "topology_hash": manifest.fleet.topology_hash,
420    })
421}
422
423// Read and decode an optional source-to-target restore mapping from disk.
424fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, BackupCommandError> {
425    let data = fs::read_to_string(path)?;
426    serde_json::from_str(&data).map_err(BackupCommandError::from)
427}
428
429// Read the next required option value.
430fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, BackupCommandError>
431where
432    I: Iterator<Item = OsString>,
433{
434    args.next()
435        .and_then(|value| value.into_string().ok())
436        .ok_or(BackupCommandError::MissingValue(option))
437}
438
439// Return backup command usage text.
440const fn usage() -> &'static str {
441    "usage: canic backup preflight --dir <backup-dir> --out-dir <dir> [--mapping <file>]\n       canic backup status --dir <backup-dir> [--out <file>] [--require-complete]\n       canic backup verify --dir <backup-dir> [--out <file>]"
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use canic_backup::{
448        artifacts::ArtifactChecksum,
449        journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
450        manifest::{
451            BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetBackupManifest,
452            FleetMember, FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
453            VerificationCheck, VerificationPlan,
454        },
455    };
456    use std::{
457        fs,
458        path::Path,
459        time::{SystemTime, UNIX_EPOCH},
460    };
461
462    const ROOT: &str = "aaaaa-aa";
463    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
464
465    // Ensure backup preflight options parse the intended command shape.
466    #[test]
467    fn parses_backup_preflight_options() {
468        let options = BackupPreflightOptions::parse([
469            OsString::from("--dir"),
470            OsString::from("backups/run"),
471            OsString::from("--out-dir"),
472            OsString::from("reports/run"),
473            OsString::from("--mapping"),
474            OsString::from("mapping.json"),
475        ])
476        .expect("parse options");
477
478        assert_eq!(options.dir, PathBuf::from("backups/run"));
479        assert_eq!(options.out_dir, PathBuf::from("reports/run"));
480        assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
481    }
482
483    // Ensure preflight writes the standard no-mutation report bundle.
484    #[test]
485    fn backup_preflight_writes_standard_reports() {
486        let root = temp_dir("canic-cli-backup-preflight");
487        let out_dir = root.join("reports");
488        let backup_dir = root.join("backup");
489        let layout = BackupLayout::new(backup_dir.clone());
490        let checksum = write_artifact(&backup_dir, b"root artifact");
491
492        layout
493            .write_manifest(&valid_manifest())
494            .expect("write manifest");
495        layout
496            .write_journal(&journal_with_checksum(checksum.hash))
497            .expect("write journal");
498
499        let options = BackupPreflightOptions {
500            dir: backup_dir,
501            out_dir: out_dir.clone(),
502            mapping: None,
503        };
504        let report = backup_preflight(&options).expect("run preflight");
505
506        assert_eq!(report.status, "ready");
507        assert_eq!(report.backup_id, "backup-test");
508        assert_eq!(report.source_environment, "local");
509        assert_eq!(report.source_root_canister, ROOT);
510        assert_eq!(report.topology_hash, HASH);
511        assert_eq!(report.mapping_path, None);
512        assert!(report.journal_complete);
513        assert!(report.integrity_verified);
514        assert_eq!(report.manifest_members, 1);
515        assert_eq!(report.restore_plan_members, 1);
516        assert!(out_dir.join("manifest-validation.json").exists());
517        assert!(out_dir.join("backup-status.json").exists());
518        assert!(out_dir.join("backup-integrity.json").exists());
519        assert!(out_dir.join("restore-plan.json").exists());
520        assert!(out_dir.join("preflight-summary.json").exists());
521
522        let summary: serde_json::Value = serde_json::from_slice(
523            &fs::read(out_dir.join("preflight-summary.json")).expect("read summary"),
524        )
525        .expect("decode summary");
526
527        fs::remove_dir_all(root).expect("remove temp root");
528        assert_eq!(summary["status"], report.status);
529        assert_eq!(summary["backup_id"], report.backup_id);
530        assert_eq!(summary["source_environment"], report.source_environment);
531        assert_eq!(summary["source_root_canister"], report.source_root_canister);
532        assert_eq!(summary["topology_hash"], report.topology_hash);
533        assert_eq!(summary["journal_complete"], report.journal_complete);
534        assert_eq!(summary["integrity_verified"], report.integrity_verified);
535        assert_eq!(summary["manifest_members"], report.manifest_members);
536        assert_eq!(summary["restore_plan_members"], report.restore_plan_members);
537    }
538
539    // Ensure preflight stops on incomplete journals before claiming readiness.
540    #[test]
541    fn backup_preflight_rejects_incomplete_journal() {
542        let root = temp_dir("canic-cli-backup-preflight-incomplete");
543        let out_dir = root.join("reports");
544        let backup_dir = root.join("backup");
545        let layout = BackupLayout::new(backup_dir.clone());
546
547        layout
548            .write_manifest(&valid_manifest())
549            .expect("write manifest");
550        layout
551            .write_journal(&created_journal())
552            .expect("write journal");
553
554        let options = BackupPreflightOptions {
555            dir: backup_dir,
556            out_dir,
557            mapping: None,
558        };
559
560        let err = backup_preflight(&options).expect_err("incomplete journal should fail");
561
562        fs::remove_dir_all(root).expect("remove temp root");
563        assert!(matches!(
564            err,
565            BackupCommandError::IncompleteJournal {
566                pending_artifacts: 1,
567                total_artifacts: 1,
568                ..
569            }
570        ));
571    }
572
573    // Ensure backup verification options parse the intended command shape.
574    #[test]
575    fn parses_backup_verify_options() {
576        let options = BackupVerifyOptions::parse([
577            OsString::from("--dir"),
578            OsString::from("backups/run"),
579            OsString::from("--out"),
580            OsString::from("report.json"),
581        ])
582        .expect("parse options");
583
584        assert_eq!(options.dir, PathBuf::from("backups/run"));
585        assert_eq!(options.out, Some(PathBuf::from("report.json")));
586    }
587
588    // Ensure backup status options parse the intended command shape.
589    #[test]
590    fn parses_backup_status_options() {
591        let options = BackupStatusOptions::parse([
592            OsString::from("--dir"),
593            OsString::from("backups/run"),
594            OsString::from("--out"),
595            OsString::from("status.json"),
596            OsString::from("--require-complete"),
597        ])
598        .expect("parse options");
599
600        assert_eq!(options.dir, PathBuf::from("backups/run"));
601        assert_eq!(options.out, Some(PathBuf::from("status.json")));
602        assert!(options.require_complete);
603    }
604
605    // Ensure backup status reads the journal and reports resume actions.
606    #[test]
607    fn backup_status_reads_journal_resume_report() {
608        let root = temp_dir("canic-cli-backup-status");
609        let layout = BackupLayout::new(root.clone());
610        layout
611            .write_journal(&journal_with_checksum(HASH.to_string()))
612            .expect("write journal");
613
614        let options = BackupStatusOptions {
615            dir: root.clone(),
616            out: None,
617            require_complete: false,
618        };
619        let report = backup_status(&options).expect("read backup status");
620
621        fs::remove_dir_all(root).expect("remove temp root");
622        assert_eq!(report.backup_id, "backup-test");
623        assert_eq!(report.total_artifacts, 1);
624        assert!(report.is_complete);
625        assert_eq!(report.pending_artifacts, 0);
626        assert_eq!(report.counts.skip, 1);
627    }
628
629    // Ensure require-complete accepts already durable backup journals.
630    #[test]
631    fn require_complete_accepts_complete_status() {
632        let options = BackupStatusOptions {
633            dir: PathBuf::from("unused"),
634            out: None,
635            require_complete: true,
636        };
637        let report = journal_with_checksum(HASH.to_string()).resume_report();
638
639        enforce_status_requirements(&options, &report).expect("complete status should pass");
640    }
641
642    // Ensure require-complete rejects journals that still need resume work.
643    #[test]
644    fn require_complete_rejects_incomplete_status() {
645        let options = BackupStatusOptions {
646            dir: PathBuf::from("unused"),
647            out: None,
648            require_complete: true,
649        };
650        let report = created_journal().resume_report();
651
652        let err = enforce_status_requirements(&options, &report)
653            .expect_err("incomplete status should fail");
654
655        assert!(matches!(
656            err,
657            BackupCommandError::IncompleteJournal {
658                pending_artifacts: 1,
659                total_artifacts: 1,
660                ..
661            }
662        ));
663    }
664
665    // Ensure the CLI verification path reads a layout and returns an integrity report.
666    #[test]
667    fn verify_backup_reads_layout_and_artifacts() {
668        let root = temp_dir("canic-cli-backup-verify");
669        let layout = BackupLayout::new(root.clone());
670        let checksum = write_artifact(&root, b"root artifact");
671
672        layout
673            .write_manifest(&valid_manifest())
674            .expect("write manifest");
675        layout
676            .write_journal(&journal_with_checksum(checksum.hash.clone()))
677            .expect("write journal");
678
679        let options = BackupVerifyOptions {
680            dir: root.clone(),
681            out: None,
682        };
683        let report = verify_backup(&options).expect("verify backup");
684
685        fs::remove_dir_all(root).expect("remove temp root");
686        assert_eq!(report.backup_id, "backup-test");
687        assert!(report.verified);
688        assert_eq!(report.durable_artifacts, 1);
689        assert_eq!(report.artifacts[0].checksum, checksum.hash);
690    }
691
692    // Build one valid manifest for CLI verification tests.
693    fn valid_manifest() -> FleetBackupManifest {
694        FleetBackupManifest {
695            manifest_version: 1,
696            backup_id: "backup-test".to_string(),
697            created_at: "2026-05-03T00:00:00Z".to_string(),
698            tool: ToolMetadata {
699                name: "canic".to_string(),
700                version: "0.30.3".to_string(),
701            },
702            source: SourceMetadata {
703                environment: "local".to_string(),
704                root_canister: ROOT.to_string(),
705            },
706            consistency: ConsistencySection {
707                mode: ConsistencyMode::CrashConsistent,
708                backup_units: vec![BackupUnit {
709                    unit_id: "fleet".to_string(),
710                    kind: BackupUnitKind::SubtreeRooted,
711                    roles: vec!["root".to_string()],
712                    consistency_reason: None,
713                    dependency_closure: Vec::new(),
714                    topology_validation: "subtree-closed".to_string(),
715                    quiescence_strategy: None,
716                }],
717            },
718            fleet: FleetSection {
719                topology_hash_algorithm: "sha256".to_string(),
720                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
721                discovery_topology_hash: HASH.to_string(),
722                pre_snapshot_topology_hash: HASH.to_string(),
723                topology_hash: HASH.to_string(),
724                members: vec![fleet_member()],
725            },
726            verification: VerificationPlan::default(),
727        }
728    }
729
730    // Build one valid manifest member.
731    fn fleet_member() -> FleetMember {
732        FleetMember {
733            role: "root".to_string(),
734            canister_id: ROOT.to_string(),
735            parent_canister_id: None,
736            subnet_canister_id: Some(ROOT.to_string()),
737            controller_hint: None,
738            identity_mode: IdentityMode::Fixed,
739            restore_group: 1,
740            verification_class: "basic".to_string(),
741            verification_checks: vec![VerificationCheck {
742                kind: "status".to_string(),
743                method: None,
744                roles: vec!["root".to_string()],
745            }],
746            source_snapshot: SourceSnapshot {
747                snapshot_id: "root-snapshot".to_string(),
748                module_hash: None,
749                wasm_hash: None,
750                code_version: Some("v0.30.3".to_string()),
751                artifact_path: "artifacts/root".to_string(),
752                checksum_algorithm: "sha256".to_string(),
753                checksum: None,
754            },
755        }
756    }
757
758    // Build one durable journal with a caller-provided checksum.
759    fn journal_with_checksum(checksum: String) -> DownloadJournal {
760        DownloadJournal {
761            journal_version: 1,
762            backup_id: "backup-test".to_string(),
763            artifacts: vec![ArtifactJournalEntry {
764                canister_id: ROOT.to_string(),
765                snapshot_id: "root-snapshot".to_string(),
766                state: ArtifactState::Durable,
767                temp_path: None,
768                artifact_path: "artifacts/root".to_string(),
769                checksum_algorithm: "sha256".to_string(),
770                checksum: Some(checksum),
771                updated_at: "2026-05-03T00:00:00Z".to_string(),
772            }],
773        }
774    }
775
776    // Build one incomplete journal that still needs artifact download work.
777    fn created_journal() -> DownloadJournal {
778        DownloadJournal {
779            journal_version: 1,
780            backup_id: "backup-test".to_string(),
781            artifacts: vec![ArtifactJournalEntry {
782                canister_id: ROOT.to_string(),
783                snapshot_id: "root-snapshot".to_string(),
784                state: ArtifactState::Created,
785                temp_path: None,
786                artifact_path: "artifacts/root".to_string(),
787                checksum_algorithm: "sha256".to_string(),
788                checksum: None,
789                updated_at: "2026-05-03T00:00:00Z".to_string(),
790            }],
791        }
792    }
793
794    // Write one artifact at the layout-relative path used by test journals.
795    fn write_artifact(root: &Path, bytes: &[u8]) -> ArtifactChecksum {
796        let path = root.join("artifacts/root");
797        fs::create_dir_all(path.parent().expect("artifact has parent")).expect("create artifacts");
798        fs::write(&path, bytes).expect("write artifact");
799        ArtifactChecksum::from_bytes(bytes)
800    }
801
802    // Build a unique temporary directory.
803    fn temp_dir(prefix: &str) -> PathBuf {
804        let nanos = SystemTime::now()
805            .duration_since(UNIX_EPOCH)
806            .expect("system time after epoch")
807            .as_nanos();
808        std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
809    }
810}