Skip to main content

canic_cli/restore/
mod.rs

1use canic_backup::{
2    manifest::FleetBackupManifest,
3    persistence::{BackupLayout, PersistenceError},
4    restore::{
5        RestoreApplyDryRun, RestoreApplyDryRunError, RestoreMapping, RestorePlan, RestorePlanError,
6        RestorePlanner, RestoreStatus,
7    },
8};
9use std::{
10    ffi::OsString,
11    fs,
12    io::{self, Write},
13    path::PathBuf,
14};
15use thiserror::Error as ThisError;
16
17///
18/// RestoreCommandError
19///
20
21#[derive(Debug, ThisError)]
22pub enum RestoreCommandError {
23    #[error("{0}")]
24    Usage(&'static str),
25
26    #[error("missing required option {0}")]
27    MissingOption(&'static str),
28
29    #[error("use either --manifest or --backup-dir, not both")]
30    ConflictingManifestSources,
31
32    #[error("--require-verified requires --backup-dir")]
33    RequireVerifiedNeedsBackupDir,
34
35    #[error("restore apply currently requires --dry-run")]
36    ApplyRequiresDryRun,
37
38    #[error("restore plan for backup {backup_id} is not restore-ready: reasons={reasons:?}")]
39    RestoreNotReady {
40        backup_id: String,
41        reasons: Vec<String>,
42    },
43
44    #[error("unknown option {0}")]
45    UnknownOption(String),
46
47    #[error("option {0} requires a value")]
48    MissingValue(&'static str),
49
50    #[error(transparent)]
51    Io(#[from] std::io::Error),
52
53    #[error(transparent)]
54    Json(#[from] serde_json::Error),
55
56    #[error(transparent)]
57    Persistence(#[from] PersistenceError),
58
59    #[error(transparent)]
60    RestorePlan(#[from] RestorePlanError),
61
62    #[error(transparent)]
63    RestoreApplyDryRun(#[from] RestoreApplyDryRunError),
64}
65
66///
67/// RestorePlanOptions
68///
69
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct RestorePlanOptions {
72    pub manifest: Option<PathBuf>,
73    pub backup_dir: Option<PathBuf>,
74    pub mapping: Option<PathBuf>,
75    pub out: Option<PathBuf>,
76    pub require_verified: bool,
77    pub require_restore_ready: bool,
78}
79
80impl RestorePlanOptions {
81    /// Parse restore planning options from CLI arguments.
82    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
83    where
84        I: IntoIterator<Item = OsString>,
85    {
86        let mut manifest = None;
87        let mut backup_dir = None;
88        let mut mapping = None;
89        let mut out = None;
90        let mut require_verified = false;
91        let mut require_restore_ready = false;
92
93        let mut args = args.into_iter();
94        while let Some(arg) = args.next() {
95            let arg = arg
96                .into_string()
97                .map_err(|_| RestoreCommandError::Usage(usage()))?;
98            match arg.as_str() {
99                "--manifest" => {
100                    manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
101                }
102                "--backup-dir" => {
103                    backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
104                }
105                "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
106                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
107                "--require-verified" => require_verified = true,
108                "--require-restore-ready" => require_restore_ready = true,
109                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
110                _ => return Err(RestoreCommandError::UnknownOption(arg)),
111            }
112        }
113
114        if manifest.is_some() && backup_dir.is_some() {
115            return Err(RestoreCommandError::ConflictingManifestSources);
116        }
117
118        if manifest.is_none() && backup_dir.is_none() {
119            return Err(RestoreCommandError::MissingOption(
120                "--manifest or --backup-dir",
121            ));
122        }
123
124        if require_verified && backup_dir.is_none() {
125            return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
126        }
127
128        Ok(Self {
129            manifest,
130            backup_dir,
131            mapping,
132            out,
133            require_verified,
134            require_restore_ready,
135        })
136    }
137}
138
139///
140/// RestoreStatusOptions
141///
142
143#[derive(Clone, Debug, Eq, PartialEq)]
144pub struct RestoreStatusOptions {
145    pub plan: PathBuf,
146    pub out: Option<PathBuf>,
147}
148
149impl RestoreStatusOptions {
150    /// Parse restore status options from CLI arguments.
151    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
152    where
153        I: IntoIterator<Item = OsString>,
154    {
155        let mut plan = None;
156        let mut out = None;
157
158        let mut args = args.into_iter();
159        while let Some(arg) = args.next() {
160            let arg = arg
161                .into_string()
162                .map_err(|_| RestoreCommandError::Usage(usage()))?;
163            match arg.as_str() {
164                "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
165                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
166                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
167                _ => return Err(RestoreCommandError::UnknownOption(arg)),
168            }
169        }
170
171        Ok(Self {
172            plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
173            out,
174        })
175    }
176}
177
178///
179/// RestoreApplyOptions
180///
181
182#[derive(Clone, Debug, Eq, PartialEq)]
183pub struct RestoreApplyOptions {
184    pub plan: PathBuf,
185    pub status: Option<PathBuf>,
186    pub backup_dir: Option<PathBuf>,
187    pub out: Option<PathBuf>,
188    pub dry_run: bool,
189}
190
191impl RestoreApplyOptions {
192    /// Parse restore apply options from CLI arguments.
193    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
194    where
195        I: IntoIterator<Item = OsString>,
196    {
197        let mut plan = None;
198        let mut status = None;
199        let mut backup_dir = None;
200        let mut out = None;
201        let mut dry_run = false;
202
203        let mut args = args.into_iter();
204        while let Some(arg) = args.next() {
205            let arg = arg
206                .into_string()
207                .map_err(|_| RestoreCommandError::Usage(usage()))?;
208            match arg.as_str() {
209                "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
210                "--status" => status = Some(PathBuf::from(next_value(&mut args, "--status")?)),
211                "--backup-dir" => {
212                    backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
213                }
214                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
215                "--dry-run" => dry_run = true,
216                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
217                _ => return Err(RestoreCommandError::UnknownOption(arg)),
218            }
219        }
220
221        if !dry_run {
222            return Err(RestoreCommandError::ApplyRequiresDryRun);
223        }
224
225        Ok(Self {
226            plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
227            status,
228            backup_dir,
229            out,
230            dry_run,
231        })
232    }
233}
234
235/// Run a restore subcommand.
236pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
237where
238    I: IntoIterator<Item = OsString>,
239{
240    let mut args = args.into_iter();
241    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
242        return Err(RestoreCommandError::Usage(usage()));
243    };
244
245    match command.as_str() {
246        "plan" => {
247            let options = RestorePlanOptions::parse(args)?;
248            let plan = plan_restore(&options)?;
249            write_plan(&options, &plan)?;
250            enforce_restore_plan_requirements(&options, &plan)?;
251            Ok(())
252        }
253        "status" => {
254            let options = RestoreStatusOptions::parse(args)?;
255            let status = restore_status(&options)?;
256            write_status(&options, &status)?;
257            Ok(())
258        }
259        "apply" => {
260            let options = RestoreApplyOptions::parse(args)?;
261            let dry_run = restore_apply_dry_run(&options)?;
262            write_apply_dry_run(&options, &dry_run)?;
263            Ok(())
264        }
265        "help" | "--help" | "-h" => Err(RestoreCommandError::Usage(usage())),
266        _ => Err(RestoreCommandError::UnknownOption(command)),
267    }
268}
269
270/// Build a no-mutation restore plan from a manifest and optional mapping.
271pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
272    verify_backup_layout_if_required(options)?;
273
274    let manifest = read_manifest_source(options)?;
275    let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
276
277    RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
278}
279
280/// Build the initial no-mutation restore status from a restore plan.
281pub fn restore_status(
282    options: &RestoreStatusOptions,
283) -> Result<RestoreStatus, RestoreCommandError> {
284    let plan = read_plan(&options.plan)?;
285    Ok(RestoreStatus::from_plan(&plan))
286}
287
288/// Build a no-mutation restore apply dry-run from a restore plan.
289pub fn restore_apply_dry_run(
290    options: &RestoreApplyOptions,
291) -> Result<RestoreApplyDryRun, RestoreCommandError> {
292    let plan = read_plan(&options.plan)?;
293    let status = options.status.as_ref().map(read_status).transpose()?;
294    if let Some(backup_dir) = &options.backup_dir {
295        return RestoreApplyDryRun::try_from_plan_with_artifacts(
296            &plan,
297            status.as_ref(),
298            backup_dir,
299        )
300        .map_err(RestoreCommandError::from);
301    }
302
303    RestoreApplyDryRun::try_from_plan(&plan, status.as_ref()).map_err(RestoreCommandError::from)
304}
305
306// Enforce caller-requested restore plan requirements after the plan is emitted.
307fn enforce_restore_plan_requirements(
308    options: &RestorePlanOptions,
309    plan: &RestorePlan,
310) -> Result<(), RestoreCommandError> {
311    if !options.require_restore_ready || plan.readiness_summary.ready {
312        return Ok(());
313    }
314
315    Err(RestoreCommandError::RestoreNotReady {
316        backup_id: plan.backup_id.clone(),
317        reasons: plan.readiness_summary.reasons.clone(),
318    })
319}
320
321// Verify backup layout integrity before restore planning when requested.
322fn verify_backup_layout_if_required(
323    options: &RestorePlanOptions,
324) -> Result<(), RestoreCommandError> {
325    if !options.require_verified {
326        return Ok(());
327    }
328
329    let Some(dir) = &options.backup_dir else {
330        return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
331    };
332
333    BackupLayout::new(dir.clone()).verify_integrity()?;
334    Ok(())
335}
336
337// Read the manifest from a direct path or canonical backup layout.
338fn read_manifest_source(
339    options: &RestorePlanOptions,
340) -> Result<FleetBackupManifest, RestoreCommandError> {
341    if let Some(path) = &options.manifest {
342        return read_manifest(path);
343    }
344
345    let Some(dir) = &options.backup_dir else {
346        return Err(RestoreCommandError::MissingOption(
347            "--manifest or --backup-dir",
348        ));
349    };
350
351    BackupLayout::new(dir.clone())
352        .read_manifest()
353        .map_err(RestoreCommandError::from)
354}
355
356// Read and decode a fleet backup manifest from disk.
357fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
358    let data = fs::read_to_string(path)?;
359    serde_json::from_str(&data).map_err(RestoreCommandError::from)
360}
361
362// Read and decode an optional source-to-target restore mapping from disk.
363fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
364    let data = fs::read_to_string(path)?;
365    serde_json::from_str(&data).map_err(RestoreCommandError::from)
366}
367
368// Read and decode a restore plan from disk.
369fn read_plan(path: &PathBuf) -> Result<RestorePlan, RestoreCommandError> {
370    let data = fs::read_to_string(path)?;
371    serde_json::from_str(&data).map_err(RestoreCommandError::from)
372}
373
374// Read and decode a restore status from disk.
375fn read_status(path: &PathBuf) -> Result<RestoreStatus, RestoreCommandError> {
376    let data = fs::read_to_string(path)?;
377    serde_json::from_str(&data).map_err(RestoreCommandError::from)
378}
379
380// Write the computed plan to stdout or a requested output file.
381fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
382    if let Some(path) = &options.out {
383        let data = serde_json::to_vec_pretty(plan)?;
384        fs::write(path, data)?;
385        return Ok(());
386    }
387
388    let stdout = io::stdout();
389    let mut handle = stdout.lock();
390    serde_json::to_writer_pretty(&mut handle, plan)?;
391    writeln!(handle)?;
392    Ok(())
393}
394
395// Write the computed status to stdout or a requested output file.
396fn write_status(
397    options: &RestoreStatusOptions,
398    status: &RestoreStatus,
399) -> Result<(), RestoreCommandError> {
400    if let Some(path) = &options.out {
401        let data = serde_json::to_vec_pretty(status)?;
402        fs::write(path, data)?;
403        return Ok(());
404    }
405
406    let stdout = io::stdout();
407    let mut handle = stdout.lock();
408    serde_json::to_writer_pretty(&mut handle, status)?;
409    writeln!(handle)?;
410    Ok(())
411}
412
413// Write the computed apply dry-run to stdout or a requested output file.
414fn write_apply_dry_run(
415    options: &RestoreApplyOptions,
416    dry_run: &RestoreApplyDryRun,
417) -> Result<(), RestoreCommandError> {
418    if let Some(path) = &options.out {
419        let data = serde_json::to_vec_pretty(dry_run)?;
420        fs::write(path, data)?;
421        return Ok(());
422    }
423
424    let stdout = io::stdout();
425    let mut handle = stdout.lock();
426    serde_json::to_writer_pretty(&mut handle, dry_run)?;
427    writeln!(handle)?;
428    Ok(())
429}
430
431// Read the next required option value.
432fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, RestoreCommandError>
433where
434    I: Iterator<Item = OsString>,
435{
436    args.next()
437        .and_then(|value| value.into_string().ok())
438        .ok_or(RestoreCommandError::MissingValue(option))
439}
440
441// Return restore command usage text.
442const fn usage() -> &'static str {
443    "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>]\n       canic restore apply --plan <file> [--status <file>] [--backup-dir <dir>] --dry-run [--out <file>]"
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use canic_backup::{
450        artifacts::ArtifactChecksum,
451        journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
452        manifest::{
453            BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember,
454            FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
455            VerificationCheck, VerificationPlan,
456        },
457    };
458    use serde_json::json;
459    use std::{
460        path::Path,
461        time::{SystemTime, UNIX_EPOCH},
462    };
463
464    const ROOT: &str = "aaaaa-aa";
465    const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
466    const MAPPED_CHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
467    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
468
469    // Ensure restore plan options parse the intended no-mutation command.
470    #[test]
471    fn parses_restore_plan_options() {
472        let options = RestorePlanOptions::parse([
473            OsString::from("--manifest"),
474            OsString::from("manifest.json"),
475            OsString::from("--mapping"),
476            OsString::from("mapping.json"),
477            OsString::from("--out"),
478            OsString::from("plan.json"),
479            OsString::from("--require-restore-ready"),
480        ])
481        .expect("parse options");
482
483        assert_eq!(options.manifest, Some(PathBuf::from("manifest.json")));
484        assert_eq!(options.backup_dir, None);
485        assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
486        assert_eq!(options.out, Some(PathBuf::from("plan.json")));
487        assert!(!options.require_verified);
488        assert!(options.require_restore_ready);
489    }
490
491    // Ensure verified restore plan options parse with the canonical backup source.
492    #[test]
493    fn parses_verified_restore_plan_options() {
494        let options = RestorePlanOptions::parse([
495            OsString::from("--backup-dir"),
496            OsString::from("backups/run"),
497            OsString::from("--require-verified"),
498        ])
499        .expect("parse verified options");
500
501        assert_eq!(options.manifest, None);
502        assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
503        assert_eq!(options.mapping, None);
504        assert_eq!(options.out, None);
505        assert!(options.require_verified);
506        assert!(!options.require_restore_ready);
507    }
508
509    // Ensure restore status options parse the intended no-mutation command.
510    #[test]
511    fn parses_restore_status_options() {
512        let options = RestoreStatusOptions::parse([
513            OsString::from("--plan"),
514            OsString::from("restore-plan.json"),
515            OsString::from("--out"),
516            OsString::from("restore-status.json"),
517        ])
518        .expect("parse status options");
519
520        assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
521        assert_eq!(options.out, Some(PathBuf::from("restore-status.json")));
522    }
523
524    // Ensure restore apply options require the explicit dry-run mode.
525    #[test]
526    fn parses_restore_apply_dry_run_options() {
527        let options = RestoreApplyOptions::parse([
528            OsString::from("--plan"),
529            OsString::from("restore-plan.json"),
530            OsString::from("--status"),
531            OsString::from("restore-status.json"),
532            OsString::from("--backup-dir"),
533            OsString::from("backups/run"),
534            OsString::from("--dry-run"),
535            OsString::from("--out"),
536            OsString::from("restore-apply-dry-run.json"),
537        ])
538        .expect("parse apply options");
539
540        assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
541        assert_eq!(options.status, Some(PathBuf::from("restore-status.json")));
542        assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
543        assert_eq!(
544            options.out,
545            Some(PathBuf::from("restore-apply-dry-run.json"))
546        );
547        assert!(options.dry_run);
548    }
549
550    // Ensure restore apply refuses non-dry-run execution while apply is scaffolded.
551    #[test]
552    fn restore_apply_requires_dry_run() {
553        let err = RestoreApplyOptions::parse([
554            OsString::from("--plan"),
555            OsString::from("restore-plan.json"),
556        ])
557        .expect_err("apply without dry-run should fail");
558
559        assert!(matches!(err, RestoreCommandError::ApplyRequiresDryRun));
560    }
561
562    // Ensure backup-dir restore planning reads the canonical layout manifest.
563    #[test]
564    fn plan_restore_reads_manifest_from_backup_dir() {
565        let root = temp_dir("canic-cli-restore-plan-layout");
566        let layout = BackupLayout::new(root.clone());
567        layout
568            .write_manifest(&valid_manifest())
569            .expect("write manifest");
570
571        let options = RestorePlanOptions {
572            manifest: None,
573            backup_dir: Some(root.clone()),
574            mapping: None,
575            out: None,
576            require_verified: false,
577            require_restore_ready: false,
578        };
579
580        let plan = plan_restore(&options).expect("plan restore");
581
582        fs::remove_dir_all(root).expect("remove temp root");
583        assert_eq!(plan.backup_id, "backup-test");
584        assert_eq!(plan.member_count, 2);
585    }
586
587    // Ensure restore planning has exactly one manifest source.
588    #[test]
589    fn parse_rejects_conflicting_manifest_sources() {
590        let err = RestorePlanOptions::parse([
591            OsString::from("--manifest"),
592            OsString::from("manifest.json"),
593            OsString::from("--backup-dir"),
594            OsString::from("backups/run"),
595        ])
596        .expect_err("conflicting sources should fail");
597
598        assert!(matches!(
599            err,
600            RestoreCommandError::ConflictingManifestSources
601        ));
602    }
603
604    // Ensure verified planning requires the canonical backup layout source.
605    #[test]
606    fn parse_rejects_require_verified_with_manifest_source() {
607        let err = RestorePlanOptions::parse([
608            OsString::from("--manifest"),
609            OsString::from("manifest.json"),
610            OsString::from("--require-verified"),
611        ])
612        .expect_err("verification should require a backup layout");
613
614        assert!(matches!(
615            err,
616            RestoreCommandError::RequireVerifiedNeedsBackupDir
617        ));
618    }
619
620    // Ensure restore planning can require manifest, journal, and artifact integrity.
621    #[test]
622    fn plan_restore_requires_verified_backup_layout() {
623        let root = temp_dir("canic-cli-restore-plan-verified");
624        let layout = BackupLayout::new(root.clone());
625        let manifest = valid_manifest();
626        write_verified_layout(&root, &layout, &manifest);
627
628        let options = RestorePlanOptions {
629            manifest: None,
630            backup_dir: Some(root.clone()),
631            mapping: None,
632            out: None,
633            require_verified: true,
634            require_restore_ready: false,
635        };
636
637        let plan = plan_restore(&options).expect("plan verified restore");
638
639        fs::remove_dir_all(root).expect("remove temp root");
640        assert_eq!(plan.backup_id, "backup-test");
641        assert_eq!(plan.member_count, 2);
642    }
643
644    // Ensure required verification fails before planning when the layout is incomplete.
645    #[test]
646    fn plan_restore_rejects_unverified_backup_layout() {
647        let root = temp_dir("canic-cli-restore-plan-unverified");
648        let layout = BackupLayout::new(root.clone());
649        layout
650            .write_manifest(&valid_manifest())
651            .expect("write manifest");
652
653        let options = RestorePlanOptions {
654            manifest: None,
655            backup_dir: Some(root.clone()),
656            mapping: None,
657            out: None,
658            require_verified: true,
659            require_restore_ready: false,
660        };
661
662        let err = plan_restore(&options).expect_err("missing journal should fail");
663
664        fs::remove_dir_all(root).expect("remove temp root");
665        assert!(matches!(err, RestoreCommandError::Persistence(_)));
666    }
667
668    // Ensure the CLI planning path validates manifests and applies mappings.
669    #[test]
670    fn plan_restore_reads_manifest_and_mapping() {
671        let root = temp_dir("canic-cli-restore-plan");
672        fs::create_dir_all(&root).expect("create temp root");
673        let manifest_path = root.join("manifest.json");
674        let mapping_path = root.join("mapping.json");
675
676        fs::write(
677            &manifest_path,
678            serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
679        )
680        .expect("write manifest");
681        fs::write(
682            &mapping_path,
683            json!({
684                "members": [
685                    {"source_canister": ROOT, "target_canister": ROOT},
686                    {"source_canister": CHILD, "target_canister": MAPPED_CHILD}
687                ]
688            })
689            .to_string(),
690        )
691        .expect("write mapping");
692
693        let options = RestorePlanOptions {
694            manifest: Some(manifest_path),
695            backup_dir: None,
696            mapping: Some(mapping_path),
697            out: None,
698            require_verified: false,
699            require_restore_ready: false,
700        };
701
702        let plan = plan_restore(&options).expect("plan restore");
703
704        fs::remove_dir_all(root).expect("remove temp root");
705        let members = plan.ordered_members();
706        assert_eq!(members.len(), 2);
707        assert_eq!(members[0].source_canister, ROOT);
708        assert_eq!(members[1].target_canister, MAPPED_CHILD);
709    }
710
711    // Ensure restore-readiness gating happens after writing the plan artifact.
712    #[test]
713    fn run_restore_plan_require_restore_ready_writes_plan_then_fails() {
714        let root = temp_dir("canic-cli-restore-plan-require-ready");
715        fs::create_dir_all(&root).expect("create temp root");
716        let manifest_path = root.join("manifest.json");
717        let out_path = root.join("plan.json");
718
719        fs::write(
720            &manifest_path,
721            serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
722        )
723        .expect("write manifest");
724
725        let err = run([
726            OsString::from("plan"),
727            OsString::from("--manifest"),
728            OsString::from(manifest_path.as_os_str()),
729            OsString::from("--out"),
730            OsString::from(out_path.as_os_str()),
731            OsString::from("--require-restore-ready"),
732        ])
733        .expect_err("restore readiness should be enforced");
734
735        assert!(out_path.exists());
736        let plan: RestorePlan =
737            serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
738
739        fs::remove_dir_all(root).expect("remove temp root");
740        assert!(!plan.readiness_summary.ready);
741        assert!(matches!(
742            err,
743            RestoreCommandError::RestoreNotReady {
744                reasons,
745                ..
746            } if reasons == [
747                "missing-module-hash",
748                "missing-wasm-hash",
749                "missing-snapshot-checksum"
750            ]
751        ));
752    }
753
754    // Ensure restore-readiness gating accepts plans with complete provenance.
755    #[test]
756    fn run_restore_plan_require_restore_ready_accepts_ready_plan() {
757        let root = temp_dir("canic-cli-restore-plan-ready");
758        fs::create_dir_all(&root).expect("create temp root");
759        let manifest_path = root.join("manifest.json");
760        let out_path = root.join("plan.json");
761
762        fs::write(
763            &manifest_path,
764            serde_json::to_vec(&restore_ready_manifest()).expect("serialize manifest"),
765        )
766        .expect("write manifest");
767
768        run([
769            OsString::from("plan"),
770            OsString::from("--manifest"),
771            OsString::from(manifest_path.as_os_str()),
772            OsString::from("--out"),
773            OsString::from(out_path.as_os_str()),
774            OsString::from("--require-restore-ready"),
775        ])
776        .expect("restore-ready plan should pass");
777
778        let plan: RestorePlan =
779            serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
780
781        fs::remove_dir_all(root).expect("remove temp root");
782        assert!(plan.readiness_summary.ready);
783        assert!(plan.readiness_summary.reasons.is_empty());
784    }
785
786    // Ensure restore status writes the initial planned execution journal.
787    #[test]
788    fn run_restore_status_writes_planned_status() {
789        let root = temp_dir("canic-cli-restore-status");
790        fs::create_dir_all(&root).expect("create temp root");
791        let plan_path = root.join("restore-plan.json");
792        let out_path = root.join("restore-status.json");
793        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
794
795        fs::write(
796            &plan_path,
797            serde_json::to_vec(&plan).expect("serialize plan"),
798        )
799        .expect("write plan");
800
801        run([
802            OsString::from("status"),
803            OsString::from("--plan"),
804            OsString::from(plan_path.as_os_str()),
805            OsString::from("--out"),
806            OsString::from(out_path.as_os_str()),
807        ])
808        .expect("write restore status");
809
810        let status: RestoreStatus =
811            serde_json::from_slice(&fs::read(&out_path).expect("read restore status"))
812                .expect("decode restore status");
813        let status_json: serde_json::Value = serde_json::to_value(&status).expect("encode status");
814
815        fs::remove_dir_all(root).expect("remove temp root");
816        assert_eq!(status.status_version, 1);
817        assert_eq!(status.backup_id.as_str(), "backup-test");
818        assert!(status.ready);
819        assert!(status.readiness_reasons.is_empty());
820        assert_eq!(status.member_count, 2);
821        assert_eq!(status.phase_count, 1);
822        assert_eq!(status.planned_snapshot_loads, 2);
823        assert_eq!(status.planned_code_reinstalls, 2);
824        assert_eq!(status.planned_verification_checks, 2);
825        assert_eq!(status.phases[0].members[0].source_canister, ROOT);
826        assert_eq!(status_json["phases"][0]["members"][0]["state"], "planned");
827    }
828
829    // Ensure restore apply dry-run writes ordered operations from plan and status.
830    #[test]
831    fn run_restore_apply_dry_run_writes_operations() {
832        let root = temp_dir("canic-cli-restore-apply-dry-run");
833        fs::create_dir_all(&root).expect("create temp root");
834        let plan_path = root.join("restore-plan.json");
835        let status_path = root.join("restore-status.json");
836        let out_path = root.join("restore-apply-dry-run.json");
837        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
838        let status = RestoreStatus::from_plan(&plan);
839
840        fs::write(
841            &plan_path,
842            serde_json::to_vec(&plan).expect("serialize plan"),
843        )
844        .expect("write plan");
845        fs::write(
846            &status_path,
847            serde_json::to_vec(&status).expect("serialize status"),
848        )
849        .expect("write status");
850
851        run([
852            OsString::from("apply"),
853            OsString::from("--plan"),
854            OsString::from(plan_path.as_os_str()),
855            OsString::from("--status"),
856            OsString::from(status_path.as_os_str()),
857            OsString::from("--dry-run"),
858            OsString::from("--out"),
859            OsString::from(out_path.as_os_str()),
860        ])
861        .expect("write apply dry-run");
862
863        let dry_run: RestoreApplyDryRun =
864            serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
865                .expect("decode dry-run");
866        let dry_run_json: serde_json::Value =
867            serde_json::to_value(&dry_run).expect("encode dry-run");
868
869        fs::remove_dir_all(root).expect("remove temp root");
870        assert_eq!(dry_run.dry_run_version, 1);
871        assert_eq!(dry_run.backup_id.as_str(), "backup-test");
872        assert!(dry_run.ready);
873        assert!(dry_run.status_supplied);
874        assert_eq!(dry_run.member_count, 2);
875        assert_eq!(dry_run.phase_count, 1);
876        assert_eq!(dry_run.rendered_operations, 8);
877        assert_eq!(
878            dry_run_json["phases"][0]["operations"][0]["operation"],
879            "upload-snapshot"
880        );
881        assert_eq!(
882            dry_run_json["phases"][0]["operations"][3]["operation"],
883            "verify-member"
884        );
885        assert_eq!(
886            dry_run_json["phases"][0]["operations"][3]["verification_kind"],
887            "status"
888        );
889        assert_eq!(
890            dry_run_json["phases"][0]["operations"][3]["verification_method"],
891            serde_json::Value::Null
892        );
893    }
894
895    // Ensure restore apply dry-run can validate artifacts under a backup directory.
896    #[test]
897    fn run_restore_apply_dry_run_validates_backup_dir_artifacts() {
898        let root = temp_dir("canic-cli-restore-apply-artifacts");
899        fs::create_dir_all(&root).expect("create temp root");
900        let plan_path = root.join("restore-plan.json");
901        let out_path = root.join("restore-apply-dry-run.json");
902        let mut manifest = valid_manifest();
903        write_manifest_artifacts(&root, &mut manifest);
904        let plan = RestorePlanner::plan(&manifest, None).expect("build plan");
905
906        fs::write(
907            &plan_path,
908            serde_json::to_vec(&plan).expect("serialize plan"),
909        )
910        .expect("write plan");
911
912        run([
913            OsString::from("apply"),
914            OsString::from("--plan"),
915            OsString::from(plan_path.as_os_str()),
916            OsString::from("--backup-dir"),
917            OsString::from(root.as_os_str()),
918            OsString::from("--dry-run"),
919            OsString::from("--out"),
920            OsString::from(out_path.as_os_str()),
921        ])
922        .expect("write apply dry-run");
923
924        let dry_run: RestoreApplyDryRun =
925            serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
926                .expect("decode dry-run");
927        let validation = dry_run
928            .artifact_validation
929            .expect("artifact validation should be present");
930
931        fs::remove_dir_all(root).expect("remove temp root");
932        assert_eq!(validation.checked_members, 2);
933        assert!(validation.artifacts_present);
934        assert!(validation.checksums_verified);
935        assert_eq!(validation.members_with_expected_checksums, 2);
936    }
937
938    // Ensure restore apply dry-run rejects status files from another plan.
939    #[test]
940    fn run_restore_apply_dry_run_rejects_mismatched_status() {
941        let root = temp_dir("canic-cli-restore-apply-dry-run-mismatch");
942        fs::create_dir_all(&root).expect("create temp root");
943        let plan_path = root.join("restore-plan.json");
944        let status_path = root.join("restore-status.json");
945        let out_path = root.join("restore-apply-dry-run.json");
946        let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
947        let mut status = RestoreStatus::from_plan(&plan);
948        status.backup_id = "other-backup".to_string();
949
950        fs::write(
951            &plan_path,
952            serde_json::to_vec(&plan).expect("serialize plan"),
953        )
954        .expect("write plan");
955        fs::write(
956            &status_path,
957            serde_json::to_vec(&status).expect("serialize status"),
958        )
959        .expect("write status");
960
961        let err = run([
962            OsString::from("apply"),
963            OsString::from("--plan"),
964            OsString::from(plan_path.as_os_str()),
965            OsString::from("--status"),
966            OsString::from(status_path.as_os_str()),
967            OsString::from("--dry-run"),
968            OsString::from("--out"),
969            OsString::from(out_path.as_os_str()),
970        ])
971        .expect_err("mismatched status should fail");
972
973        assert!(!out_path.exists());
974        fs::remove_dir_all(root).expect("remove temp root");
975        assert!(matches!(
976            err,
977            RestoreCommandError::RestoreApplyDryRun(RestoreApplyDryRunError::StatusPlanMismatch {
978                field: "backup_id",
979                ..
980            })
981        ));
982    }
983
984    // Build one valid manifest for restore planning tests.
985    fn valid_manifest() -> FleetBackupManifest {
986        FleetBackupManifest {
987            manifest_version: 1,
988            backup_id: "backup-test".to_string(),
989            created_at: "2026-05-03T00:00:00Z".to_string(),
990            tool: ToolMetadata {
991                name: "canic".to_string(),
992                version: "0.30.1".to_string(),
993            },
994            source: SourceMetadata {
995                environment: "local".to_string(),
996                root_canister: ROOT.to_string(),
997            },
998            consistency: ConsistencySection {
999                mode: ConsistencyMode::CrashConsistent,
1000                backup_units: vec![BackupUnit {
1001                    unit_id: "fleet".to_string(),
1002                    kind: BackupUnitKind::SubtreeRooted,
1003                    roles: vec!["root".to_string(), "app".to_string()],
1004                    consistency_reason: None,
1005                    dependency_closure: Vec::new(),
1006                    topology_validation: "subtree-closed".to_string(),
1007                    quiescence_strategy: None,
1008                }],
1009            },
1010            fleet: FleetSection {
1011                topology_hash_algorithm: "sha256".to_string(),
1012                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1013                discovery_topology_hash: HASH.to_string(),
1014                pre_snapshot_topology_hash: HASH.to_string(),
1015                topology_hash: HASH.to_string(),
1016                members: vec![
1017                    fleet_member("root", ROOT, None, IdentityMode::Fixed),
1018                    fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
1019                ],
1020            },
1021            verification: VerificationPlan::default(),
1022        }
1023    }
1024
1025    // Build one manifest whose restore readiness metadata is complete.
1026    fn restore_ready_manifest() -> FleetBackupManifest {
1027        let mut manifest = valid_manifest();
1028        for member in &mut manifest.fleet.members {
1029            member.source_snapshot.module_hash = Some(HASH.to_string());
1030            member.source_snapshot.wasm_hash = Some(HASH.to_string());
1031            member.source_snapshot.checksum = Some(HASH.to_string());
1032        }
1033        manifest
1034    }
1035
1036    // Build one valid manifest member.
1037    fn fleet_member(
1038        role: &str,
1039        canister_id: &str,
1040        parent_canister_id: Option<&str>,
1041        identity_mode: IdentityMode,
1042    ) -> FleetMember {
1043        FleetMember {
1044            role: role.to_string(),
1045            canister_id: canister_id.to_string(),
1046            parent_canister_id: parent_canister_id.map(str::to_string),
1047            subnet_canister_id: Some(ROOT.to_string()),
1048            controller_hint: None,
1049            identity_mode,
1050            restore_group: 1,
1051            verification_class: "basic".to_string(),
1052            verification_checks: vec![VerificationCheck {
1053                kind: "status".to_string(),
1054                method: None,
1055                roles: vec![role.to_string()],
1056            }],
1057            source_snapshot: SourceSnapshot {
1058                snapshot_id: format!("{role}-snapshot"),
1059                module_hash: None,
1060                wasm_hash: None,
1061                code_version: Some("v0.30.1".to_string()),
1062                artifact_path: format!("artifacts/{role}"),
1063                checksum_algorithm: "sha256".to_string(),
1064                checksum: None,
1065            },
1066        }
1067    }
1068
1069    // Write a canonical backup layout whose journal checksums match the artifacts.
1070    fn write_verified_layout(root: &Path, layout: &BackupLayout, manifest: &FleetBackupManifest) {
1071        layout.write_manifest(manifest).expect("write manifest");
1072
1073        let artifacts = manifest
1074            .fleet
1075            .members
1076            .iter()
1077            .map(|member| {
1078                let bytes = format!("{} artifact", member.role);
1079                let artifact_path = root.join(&member.source_snapshot.artifact_path);
1080                if let Some(parent) = artifact_path.parent() {
1081                    fs::create_dir_all(parent).expect("create artifact parent");
1082                }
1083                fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
1084                let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
1085
1086                ArtifactJournalEntry {
1087                    canister_id: member.canister_id.clone(),
1088                    snapshot_id: member.source_snapshot.snapshot_id.clone(),
1089                    state: ArtifactState::Durable,
1090                    temp_path: None,
1091                    artifact_path: member.source_snapshot.artifact_path.clone(),
1092                    checksum_algorithm: checksum.algorithm,
1093                    checksum: Some(checksum.hash),
1094                    updated_at: "2026-05-03T00:00:00Z".to_string(),
1095                }
1096            })
1097            .collect();
1098
1099        layout
1100            .write_journal(&DownloadJournal {
1101                journal_version: 1,
1102                backup_id: manifest.backup_id.clone(),
1103                discovery_topology_hash: Some(manifest.fleet.discovery_topology_hash.clone()),
1104                pre_snapshot_topology_hash: Some(manifest.fleet.pre_snapshot_topology_hash.clone()),
1105                operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
1106                artifacts,
1107            })
1108            .expect("write journal");
1109    }
1110
1111    // Write artifact bytes and update the manifest checksums for apply validation.
1112    fn write_manifest_artifacts(root: &Path, manifest: &mut FleetBackupManifest) {
1113        for member in &mut manifest.fleet.members {
1114            let bytes = format!("{} apply artifact", member.role);
1115            let artifact_path = root.join(&member.source_snapshot.artifact_path);
1116            if let Some(parent) = artifact_path.parent() {
1117                fs::create_dir_all(parent).expect("create artifact parent");
1118            }
1119            fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
1120            let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
1121            member.source_snapshot.checksum = Some(checksum.hash);
1122        }
1123    }
1124
1125    // Build a unique temporary directory.
1126    fn temp_dir(prefix: &str) -> PathBuf {
1127        let nanos = SystemTime::now()
1128            .duration_since(UNIX_EPOCH)
1129            .expect("system time after epoch")
1130            .as_nanos();
1131        std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
1132    }
1133}