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