Skip to main content

canic_cli/restore/
mod.rs

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