Skip to main content

canic_cli/restore/
mod.rs

1use canic_backup::{
2    manifest::FleetBackupManifest,
3    persistence::{BackupLayout, PersistenceError},
4    restore::{RestoreMapping, RestorePlan, RestorePlanError, RestorePlanner},
5};
6use std::{
7    ffi::OsString,
8    fs,
9    io::{self, Write},
10    path::PathBuf,
11};
12use thiserror::Error as ThisError;
13
14///
15/// RestoreCommandError
16///
17
18#[derive(Debug, ThisError)]
19pub enum RestoreCommandError {
20    #[error("{0}")]
21    Usage(&'static str),
22
23    #[error("missing required option {0}")]
24    MissingOption(&'static str),
25
26    #[error("use either --manifest or --backup-dir, not both")]
27    ConflictingManifestSources,
28
29    #[error("--require-verified requires --backup-dir")]
30    RequireVerifiedNeedsBackupDir,
31
32    #[error("unknown option {0}")]
33    UnknownOption(String),
34
35    #[error("option {0} requires a value")]
36    MissingValue(&'static str),
37
38    #[error(transparent)]
39    Io(#[from] std::io::Error),
40
41    #[error(transparent)]
42    Json(#[from] serde_json::Error),
43
44    #[error(transparent)]
45    Persistence(#[from] PersistenceError),
46
47    #[error(transparent)]
48    RestorePlan(#[from] RestorePlanError),
49}
50
51///
52/// RestorePlanOptions
53///
54
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct RestorePlanOptions {
57    pub manifest: Option<PathBuf>,
58    pub backup_dir: Option<PathBuf>,
59    pub mapping: Option<PathBuf>,
60    pub out: Option<PathBuf>,
61    pub require_verified: bool,
62}
63
64impl RestorePlanOptions {
65    /// Parse restore planning options from CLI arguments.
66    pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
67    where
68        I: IntoIterator<Item = OsString>,
69    {
70        let mut manifest = None;
71        let mut backup_dir = None;
72        let mut mapping = None;
73        let mut out = None;
74        let mut require_verified = false;
75
76        let mut args = args.into_iter();
77        while let Some(arg) = args.next() {
78            let arg = arg
79                .into_string()
80                .map_err(|_| RestoreCommandError::Usage(usage()))?;
81            match arg.as_str() {
82                "--manifest" => {
83                    manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
84                }
85                "--backup-dir" => {
86                    backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
87                }
88                "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
89                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
90                "--require-verified" => require_verified = true,
91                "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
92                _ => return Err(RestoreCommandError::UnknownOption(arg)),
93            }
94        }
95
96        if manifest.is_some() && backup_dir.is_some() {
97            return Err(RestoreCommandError::ConflictingManifestSources);
98        }
99
100        if manifest.is_none() && backup_dir.is_none() {
101            return Err(RestoreCommandError::MissingOption(
102                "--manifest or --backup-dir",
103            ));
104        }
105
106        if require_verified && backup_dir.is_none() {
107            return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
108        }
109
110        Ok(Self {
111            manifest,
112            backup_dir,
113            mapping,
114            out,
115            require_verified,
116        })
117    }
118}
119
120/// Run a restore subcommand.
121pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
122where
123    I: IntoIterator<Item = OsString>,
124{
125    let mut args = args.into_iter();
126    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
127        return Err(RestoreCommandError::Usage(usage()));
128    };
129
130    match command.as_str() {
131        "plan" => {
132            let options = RestorePlanOptions::parse(args)?;
133            let plan = plan_restore(&options)?;
134            write_plan(&options, &plan)?;
135            Ok(())
136        }
137        "help" | "--help" | "-h" => Err(RestoreCommandError::Usage(usage())),
138        _ => Err(RestoreCommandError::UnknownOption(command)),
139    }
140}
141
142/// Build a no-mutation restore plan from a manifest and optional mapping.
143pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
144    verify_backup_layout_if_required(options)?;
145
146    let manifest = read_manifest_source(options)?;
147    let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
148
149    RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
150}
151
152// Verify backup layout integrity before restore planning when requested.
153fn verify_backup_layout_if_required(
154    options: &RestorePlanOptions,
155) -> Result<(), RestoreCommandError> {
156    if !options.require_verified {
157        return Ok(());
158    }
159
160    let Some(dir) = &options.backup_dir else {
161        return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
162    };
163
164    BackupLayout::new(dir.clone()).verify_integrity()?;
165    Ok(())
166}
167
168// Read the manifest from a direct path or canonical backup layout.
169fn read_manifest_source(
170    options: &RestorePlanOptions,
171) -> Result<FleetBackupManifest, RestoreCommandError> {
172    if let Some(path) = &options.manifest {
173        return read_manifest(path);
174    }
175
176    let Some(dir) = &options.backup_dir else {
177        return Err(RestoreCommandError::MissingOption(
178            "--manifest or --backup-dir",
179        ));
180    };
181
182    BackupLayout::new(dir.clone())
183        .read_manifest()
184        .map_err(RestoreCommandError::from)
185}
186
187// Read and decode a fleet backup manifest from disk.
188fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
189    let data = fs::read_to_string(path)?;
190    serde_json::from_str(&data).map_err(RestoreCommandError::from)
191}
192
193// Read and decode an optional source-to-target restore mapping from disk.
194fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
195    let data = fs::read_to_string(path)?;
196    serde_json::from_str(&data).map_err(RestoreCommandError::from)
197}
198
199// Write the computed plan to stdout or a requested output file.
200fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
201    if let Some(path) = &options.out {
202        let data = serde_json::to_vec_pretty(plan)?;
203        fs::write(path, data)?;
204        return Ok(());
205    }
206
207    let stdout = io::stdout();
208    let mut handle = stdout.lock();
209    serde_json::to_writer_pretty(&mut handle, plan)?;
210    writeln!(handle)?;
211    Ok(())
212}
213
214// Read the next required option value.
215fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, RestoreCommandError>
216where
217    I: Iterator<Item = OsString>,
218{
219    args.next()
220        .and_then(|value| value.into_string().ok())
221        .ok_or(RestoreCommandError::MissingValue(option))
222}
223
224// Return restore command usage text.
225const fn usage() -> &'static str {
226    "usage: canic restore plan (--manifest <file> | --backup-dir <dir>) [--mapping <file>] [--out <file>] [--require-verified]"
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use canic_backup::{
233        artifacts::ArtifactChecksum,
234        journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
235        manifest::{
236            BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember,
237            FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
238            VerificationCheck, VerificationPlan,
239        },
240    };
241    use serde_json::json;
242    use std::{
243        path::Path,
244        time::{SystemTime, UNIX_EPOCH},
245    };
246
247    const ROOT: &str = "aaaaa-aa";
248    const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
249    const MAPPED_CHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
250    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
251
252    // Ensure restore plan options parse the intended no-mutation command.
253    #[test]
254    fn parses_restore_plan_options() {
255        let options = RestorePlanOptions::parse([
256            OsString::from("--manifest"),
257            OsString::from("manifest.json"),
258            OsString::from("--mapping"),
259            OsString::from("mapping.json"),
260            OsString::from("--out"),
261            OsString::from("plan.json"),
262        ])
263        .expect("parse options");
264
265        assert_eq!(options.manifest, Some(PathBuf::from("manifest.json")));
266        assert_eq!(options.backup_dir, None);
267        assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
268        assert_eq!(options.out, Some(PathBuf::from("plan.json")));
269        assert!(!options.require_verified);
270    }
271
272    // Ensure verified restore plan options parse with the canonical backup source.
273    #[test]
274    fn parses_verified_restore_plan_options() {
275        let options = RestorePlanOptions::parse([
276            OsString::from("--backup-dir"),
277            OsString::from("backups/run"),
278            OsString::from("--require-verified"),
279        ])
280        .expect("parse verified options");
281
282        assert_eq!(options.manifest, None);
283        assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
284        assert_eq!(options.mapping, None);
285        assert_eq!(options.out, None);
286        assert!(options.require_verified);
287    }
288
289    // Ensure backup-dir restore planning reads the canonical layout manifest.
290    #[test]
291    fn plan_restore_reads_manifest_from_backup_dir() {
292        let root = temp_dir("canic-cli-restore-plan-layout");
293        let layout = BackupLayout::new(root.clone());
294        layout
295            .write_manifest(&valid_manifest())
296            .expect("write manifest");
297
298        let options = RestorePlanOptions {
299            manifest: None,
300            backup_dir: Some(root.clone()),
301            mapping: None,
302            out: None,
303            require_verified: false,
304        };
305
306        let plan = plan_restore(&options).expect("plan restore");
307
308        fs::remove_dir_all(root).expect("remove temp root");
309        assert_eq!(plan.backup_id, "backup-test");
310        assert_eq!(plan.member_count, 2);
311    }
312
313    // Ensure restore planning has exactly one manifest source.
314    #[test]
315    fn parse_rejects_conflicting_manifest_sources() {
316        let err = RestorePlanOptions::parse([
317            OsString::from("--manifest"),
318            OsString::from("manifest.json"),
319            OsString::from("--backup-dir"),
320            OsString::from("backups/run"),
321        ])
322        .expect_err("conflicting sources should fail");
323
324        assert!(matches!(
325            err,
326            RestoreCommandError::ConflictingManifestSources
327        ));
328    }
329
330    // Ensure verified planning requires the canonical backup layout source.
331    #[test]
332    fn parse_rejects_require_verified_with_manifest_source() {
333        let err = RestorePlanOptions::parse([
334            OsString::from("--manifest"),
335            OsString::from("manifest.json"),
336            OsString::from("--require-verified"),
337        ])
338        .expect_err("verification should require a backup layout");
339
340        assert!(matches!(
341            err,
342            RestoreCommandError::RequireVerifiedNeedsBackupDir
343        ));
344    }
345
346    // Ensure restore planning can require manifest, journal, and artifact integrity.
347    #[test]
348    fn plan_restore_requires_verified_backup_layout() {
349        let root = temp_dir("canic-cli-restore-plan-verified");
350        let layout = BackupLayout::new(root.clone());
351        let manifest = valid_manifest();
352        write_verified_layout(&root, &layout, &manifest);
353
354        let options = RestorePlanOptions {
355            manifest: None,
356            backup_dir: Some(root.clone()),
357            mapping: None,
358            out: None,
359            require_verified: true,
360        };
361
362        let plan = plan_restore(&options).expect("plan verified restore");
363
364        fs::remove_dir_all(root).expect("remove temp root");
365        assert_eq!(plan.backup_id, "backup-test");
366        assert_eq!(plan.member_count, 2);
367    }
368
369    // Ensure required verification fails before planning when the layout is incomplete.
370    #[test]
371    fn plan_restore_rejects_unverified_backup_layout() {
372        let root = temp_dir("canic-cli-restore-plan-unverified");
373        let layout = BackupLayout::new(root.clone());
374        layout
375            .write_manifest(&valid_manifest())
376            .expect("write manifest");
377
378        let options = RestorePlanOptions {
379            manifest: None,
380            backup_dir: Some(root.clone()),
381            mapping: None,
382            out: None,
383            require_verified: true,
384        };
385
386        let err = plan_restore(&options).expect_err("missing journal should fail");
387
388        fs::remove_dir_all(root).expect("remove temp root");
389        assert!(matches!(err, RestoreCommandError::Persistence(_)));
390    }
391
392    // Ensure the CLI planning path validates manifests and applies mappings.
393    #[test]
394    fn plan_restore_reads_manifest_and_mapping() {
395        let root = temp_dir("canic-cli-restore-plan");
396        fs::create_dir_all(&root).expect("create temp root");
397        let manifest_path = root.join("manifest.json");
398        let mapping_path = root.join("mapping.json");
399
400        fs::write(
401            &manifest_path,
402            serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
403        )
404        .expect("write manifest");
405        fs::write(
406            &mapping_path,
407            json!({
408                "members": [
409                    {"source_canister": ROOT, "target_canister": ROOT},
410                    {"source_canister": CHILD, "target_canister": MAPPED_CHILD}
411                ]
412            })
413            .to_string(),
414        )
415        .expect("write mapping");
416
417        let options = RestorePlanOptions {
418            manifest: Some(manifest_path),
419            backup_dir: None,
420            mapping: Some(mapping_path),
421            out: None,
422            require_verified: false,
423        };
424
425        let plan = plan_restore(&options).expect("plan restore");
426
427        fs::remove_dir_all(root).expect("remove temp root");
428        let members = plan.ordered_members();
429        assert_eq!(members.len(), 2);
430        assert_eq!(members[0].source_canister, ROOT);
431        assert_eq!(members[1].target_canister, MAPPED_CHILD);
432    }
433
434    // Build one valid manifest for restore planning tests.
435    fn valid_manifest() -> FleetBackupManifest {
436        FleetBackupManifest {
437            manifest_version: 1,
438            backup_id: "backup-test".to_string(),
439            created_at: "2026-05-03T00:00:00Z".to_string(),
440            tool: ToolMetadata {
441                name: "canic".to_string(),
442                version: "0.30.1".to_string(),
443            },
444            source: SourceMetadata {
445                environment: "local".to_string(),
446                root_canister: ROOT.to_string(),
447            },
448            consistency: ConsistencySection {
449                mode: ConsistencyMode::CrashConsistent,
450                backup_units: vec![BackupUnit {
451                    unit_id: "fleet".to_string(),
452                    kind: BackupUnitKind::SubtreeRooted,
453                    roles: vec!["root".to_string(), "app".to_string()],
454                    consistency_reason: None,
455                    dependency_closure: Vec::new(),
456                    topology_validation: "subtree-closed".to_string(),
457                    quiescence_strategy: None,
458                }],
459            },
460            fleet: FleetSection {
461                topology_hash_algorithm: "sha256".to_string(),
462                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
463                discovery_topology_hash: HASH.to_string(),
464                pre_snapshot_topology_hash: HASH.to_string(),
465                topology_hash: HASH.to_string(),
466                members: vec![
467                    fleet_member("root", ROOT, None, IdentityMode::Fixed),
468                    fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
469                ],
470            },
471            verification: VerificationPlan::default(),
472        }
473    }
474
475    // Build one valid manifest member.
476    fn fleet_member(
477        role: &str,
478        canister_id: &str,
479        parent_canister_id: Option<&str>,
480        identity_mode: IdentityMode,
481    ) -> FleetMember {
482        FleetMember {
483            role: role.to_string(),
484            canister_id: canister_id.to_string(),
485            parent_canister_id: parent_canister_id.map(str::to_string),
486            subnet_canister_id: Some(ROOT.to_string()),
487            controller_hint: None,
488            identity_mode,
489            restore_group: 1,
490            verification_class: "basic".to_string(),
491            verification_checks: vec![VerificationCheck {
492                kind: "status".to_string(),
493                method: None,
494                roles: vec![role.to_string()],
495            }],
496            source_snapshot: SourceSnapshot {
497                snapshot_id: format!("{role}-snapshot"),
498                module_hash: None,
499                wasm_hash: None,
500                code_version: Some("v0.30.1".to_string()),
501                artifact_path: format!("artifacts/{role}"),
502                checksum_algorithm: "sha256".to_string(),
503                checksum: None,
504            },
505        }
506    }
507
508    // Write a canonical backup layout whose journal checksums match the artifacts.
509    fn write_verified_layout(root: &Path, layout: &BackupLayout, manifest: &FleetBackupManifest) {
510        layout.write_manifest(manifest).expect("write manifest");
511
512        let artifacts = manifest
513            .fleet
514            .members
515            .iter()
516            .map(|member| {
517                let bytes = format!("{} artifact", member.role);
518                let artifact_path = root.join(&member.source_snapshot.artifact_path);
519                if let Some(parent) = artifact_path.parent() {
520                    fs::create_dir_all(parent).expect("create artifact parent");
521                }
522                fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
523                let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
524
525                ArtifactJournalEntry {
526                    canister_id: member.canister_id.clone(),
527                    snapshot_id: member.source_snapshot.snapshot_id.clone(),
528                    state: ArtifactState::Durable,
529                    temp_path: None,
530                    artifact_path: member.source_snapshot.artifact_path.clone(),
531                    checksum_algorithm: checksum.algorithm,
532                    checksum: Some(checksum.hash),
533                    updated_at: "2026-05-03T00:00:00Z".to_string(),
534                }
535            })
536            .collect();
537
538        layout
539            .write_journal(&DownloadJournal {
540                journal_version: 1,
541                backup_id: manifest.backup_id.clone(),
542                artifacts,
543            })
544            .expect("write journal");
545    }
546
547    // Build a unique temporary directory.
548    fn temp_dir(prefix: &str) -> PathBuf {
549        let nanos = SystemTime::now()
550            .duration_since(UNIX_EPOCH)
551            .expect("system time after epoch")
552            .as_nanos();
553        std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
554    }
555}