Skip to main content

canic_cli/restore/
mod.rs

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