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#[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#[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 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
120pub 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
142pub 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
152fn 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
168fn 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
187fn 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
193fn 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
199fn 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
214fn 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
224const 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 discovery_topology_hash: Some(manifest.fleet.discovery_topology_hash.clone()),
543 pre_snapshot_topology_hash: Some(manifest.fleet.pre_snapshot_topology_hash.clone()),
544 artifacts,
545 })
546 .expect("write journal");
547 }
548
549 fn temp_dir(prefix: &str) -> PathBuf {
551 let nanos = SystemTime::now()
552 .duration_since(UNIX_EPOCH)
553 .expect("system time after epoch")
554 .as_nanos();
555 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
556 }
557}