1use canic_backup::{
2 manifest::FleetBackupManifest,
3 persistence::{BackupLayout, PersistenceError},
4 restore::{RestoreMapping, RestorePlan, RestorePlanError, RestorePlanner, RestoreStatus},
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("restore plan for backup {backup_id} is not restore-ready: reasons={reasons:?}")]
33 RestoreNotReady {
34 backup_id: String,
35 reasons: Vec<String>,
36 },
37
38 #[error("unknown option {0}")]
39 UnknownOption(String),
40
41 #[error("option {0} requires a value")]
42 MissingValue(&'static str),
43
44 #[error(transparent)]
45 Io(#[from] std::io::Error),
46
47 #[error(transparent)]
48 Json(#[from] serde_json::Error),
49
50 #[error(transparent)]
51 Persistence(#[from] PersistenceError),
52
53 #[error(transparent)]
54 RestorePlan(#[from] RestorePlanError),
55}
56
57#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct RestorePlanOptions {
63 pub manifest: Option<PathBuf>,
64 pub backup_dir: Option<PathBuf>,
65 pub mapping: Option<PathBuf>,
66 pub out: Option<PathBuf>,
67 pub require_verified: bool,
68 pub require_restore_ready: bool,
69}
70
71impl RestorePlanOptions {
72 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
74 where
75 I: IntoIterator<Item = OsString>,
76 {
77 let mut manifest = None;
78 let mut backup_dir = None;
79 let mut mapping = None;
80 let mut out = None;
81 let mut require_verified = false;
82 let mut require_restore_ready = false;
83
84 let mut args = args.into_iter();
85 while let Some(arg) = args.next() {
86 let arg = arg
87 .into_string()
88 .map_err(|_| RestoreCommandError::Usage(usage()))?;
89 match arg.as_str() {
90 "--manifest" => {
91 manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
92 }
93 "--backup-dir" => {
94 backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
95 }
96 "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
97 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
98 "--require-verified" => require_verified = true,
99 "--require-restore-ready" => require_restore_ready = true,
100 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
101 _ => return Err(RestoreCommandError::UnknownOption(arg)),
102 }
103 }
104
105 if manifest.is_some() && backup_dir.is_some() {
106 return Err(RestoreCommandError::ConflictingManifestSources);
107 }
108
109 if manifest.is_none() && backup_dir.is_none() {
110 return Err(RestoreCommandError::MissingOption(
111 "--manifest or --backup-dir",
112 ));
113 }
114
115 if require_verified && backup_dir.is_none() {
116 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
117 }
118
119 Ok(Self {
120 manifest,
121 backup_dir,
122 mapping,
123 out,
124 require_verified,
125 require_restore_ready,
126 })
127 }
128}
129
130#[derive(Clone, Debug, Eq, PartialEq)]
135pub struct RestoreStatusOptions {
136 pub plan: PathBuf,
137 pub out: Option<PathBuf>,
138}
139
140impl RestoreStatusOptions {
141 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
143 where
144 I: IntoIterator<Item = OsString>,
145 {
146 let mut plan = None;
147 let mut out = None;
148
149 let mut args = args.into_iter();
150 while let Some(arg) = args.next() {
151 let arg = arg
152 .into_string()
153 .map_err(|_| RestoreCommandError::Usage(usage()))?;
154 match arg.as_str() {
155 "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
156 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
157 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
158 _ => return Err(RestoreCommandError::UnknownOption(arg)),
159 }
160 }
161
162 Ok(Self {
163 plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
164 out,
165 })
166 }
167}
168
169pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
171where
172 I: IntoIterator<Item = OsString>,
173{
174 let mut args = args.into_iter();
175 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
176 return Err(RestoreCommandError::Usage(usage()));
177 };
178
179 match command.as_str() {
180 "plan" => {
181 let options = RestorePlanOptions::parse(args)?;
182 let plan = plan_restore(&options)?;
183 write_plan(&options, &plan)?;
184 enforce_restore_plan_requirements(&options, &plan)?;
185 Ok(())
186 }
187 "status" => {
188 let options = RestoreStatusOptions::parse(args)?;
189 let status = restore_status(&options)?;
190 write_status(&options, &status)?;
191 Ok(())
192 }
193 "help" | "--help" | "-h" => Err(RestoreCommandError::Usage(usage())),
194 _ => Err(RestoreCommandError::UnknownOption(command)),
195 }
196}
197
198pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
200 verify_backup_layout_if_required(options)?;
201
202 let manifest = read_manifest_source(options)?;
203 let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
204
205 RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
206}
207
208pub fn restore_status(
210 options: &RestoreStatusOptions,
211) -> Result<RestoreStatus, RestoreCommandError> {
212 let plan = read_plan(&options.plan)?;
213 Ok(RestoreStatus::from_plan(&plan))
214}
215
216fn enforce_restore_plan_requirements(
218 options: &RestorePlanOptions,
219 plan: &RestorePlan,
220) -> Result<(), RestoreCommandError> {
221 if !options.require_restore_ready || plan.readiness_summary.ready {
222 return Ok(());
223 }
224
225 Err(RestoreCommandError::RestoreNotReady {
226 backup_id: plan.backup_id.clone(),
227 reasons: plan.readiness_summary.reasons.clone(),
228 })
229}
230
231fn verify_backup_layout_if_required(
233 options: &RestorePlanOptions,
234) -> Result<(), RestoreCommandError> {
235 if !options.require_verified {
236 return Ok(());
237 }
238
239 let Some(dir) = &options.backup_dir else {
240 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
241 };
242
243 BackupLayout::new(dir.clone()).verify_integrity()?;
244 Ok(())
245}
246
247fn read_manifest_source(
249 options: &RestorePlanOptions,
250) -> Result<FleetBackupManifest, RestoreCommandError> {
251 if let Some(path) = &options.manifest {
252 return read_manifest(path);
253 }
254
255 let Some(dir) = &options.backup_dir else {
256 return Err(RestoreCommandError::MissingOption(
257 "--manifest or --backup-dir",
258 ));
259 };
260
261 BackupLayout::new(dir.clone())
262 .read_manifest()
263 .map_err(RestoreCommandError::from)
264}
265
266fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
268 let data = fs::read_to_string(path)?;
269 serde_json::from_str(&data).map_err(RestoreCommandError::from)
270}
271
272fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
274 let data = fs::read_to_string(path)?;
275 serde_json::from_str(&data).map_err(RestoreCommandError::from)
276}
277
278fn read_plan(path: &PathBuf) -> Result<RestorePlan, RestoreCommandError> {
280 let data = fs::read_to_string(path)?;
281 serde_json::from_str(&data).map_err(RestoreCommandError::from)
282}
283
284fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
286 if let Some(path) = &options.out {
287 let data = serde_json::to_vec_pretty(plan)?;
288 fs::write(path, data)?;
289 return Ok(());
290 }
291
292 let stdout = io::stdout();
293 let mut handle = stdout.lock();
294 serde_json::to_writer_pretty(&mut handle, plan)?;
295 writeln!(handle)?;
296 Ok(())
297}
298
299fn write_status(
301 options: &RestoreStatusOptions,
302 status: &RestoreStatus,
303) -> Result<(), RestoreCommandError> {
304 if let Some(path) = &options.out {
305 let data = serde_json::to_vec_pretty(status)?;
306 fs::write(path, data)?;
307 return Ok(());
308 }
309
310 let stdout = io::stdout();
311 let mut handle = stdout.lock();
312 serde_json::to_writer_pretty(&mut handle, status)?;
313 writeln!(handle)?;
314 Ok(())
315}
316
317fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, RestoreCommandError>
319where
320 I: Iterator<Item = OsString>,
321{
322 args.next()
323 .and_then(|value| value.into_string().ok())
324 .ok_or(RestoreCommandError::MissingValue(option))
325}
326
327const fn usage() -> &'static str {
329 "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>]"
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use canic_backup::{
336 artifacts::ArtifactChecksum,
337 journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
338 manifest::{
339 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember,
340 FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
341 VerificationCheck, VerificationPlan,
342 },
343 };
344 use serde_json::json;
345 use std::{
346 path::Path,
347 time::{SystemTime, UNIX_EPOCH},
348 };
349
350 const ROOT: &str = "aaaaa-aa";
351 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
352 const MAPPED_CHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
353 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
354
355 #[test]
357 fn parses_restore_plan_options() {
358 let options = RestorePlanOptions::parse([
359 OsString::from("--manifest"),
360 OsString::from("manifest.json"),
361 OsString::from("--mapping"),
362 OsString::from("mapping.json"),
363 OsString::from("--out"),
364 OsString::from("plan.json"),
365 OsString::from("--require-restore-ready"),
366 ])
367 .expect("parse options");
368
369 assert_eq!(options.manifest, Some(PathBuf::from("manifest.json")));
370 assert_eq!(options.backup_dir, None);
371 assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
372 assert_eq!(options.out, Some(PathBuf::from("plan.json")));
373 assert!(!options.require_verified);
374 assert!(options.require_restore_ready);
375 }
376
377 #[test]
379 fn parses_verified_restore_plan_options() {
380 let options = RestorePlanOptions::parse([
381 OsString::from("--backup-dir"),
382 OsString::from("backups/run"),
383 OsString::from("--require-verified"),
384 ])
385 .expect("parse verified options");
386
387 assert_eq!(options.manifest, None);
388 assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
389 assert_eq!(options.mapping, None);
390 assert_eq!(options.out, None);
391 assert!(options.require_verified);
392 assert!(!options.require_restore_ready);
393 }
394
395 #[test]
397 fn parses_restore_status_options() {
398 let options = RestoreStatusOptions::parse([
399 OsString::from("--plan"),
400 OsString::from("restore-plan.json"),
401 OsString::from("--out"),
402 OsString::from("restore-status.json"),
403 ])
404 .expect("parse status options");
405
406 assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
407 assert_eq!(options.out, Some(PathBuf::from("restore-status.json")));
408 }
409
410 #[test]
412 fn plan_restore_reads_manifest_from_backup_dir() {
413 let root = temp_dir("canic-cli-restore-plan-layout");
414 let layout = BackupLayout::new(root.clone());
415 layout
416 .write_manifest(&valid_manifest())
417 .expect("write manifest");
418
419 let options = RestorePlanOptions {
420 manifest: None,
421 backup_dir: Some(root.clone()),
422 mapping: None,
423 out: None,
424 require_verified: false,
425 require_restore_ready: false,
426 };
427
428 let plan = plan_restore(&options).expect("plan restore");
429
430 fs::remove_dir_all(root).expect("remove temp root");
431 assert_eq!(plan.backup_id, "backup-test");
432 assert_eq!(plan.member_count, 2);
433 }
434
435 #[test]
437 fn parse_rejects_conflicting_manifest_sources() {
438 let err = RestorePlanOptions::parse([
439 OsString::from("--manifest"),
440 OsString::from("manifest.json"),
441 OsString::from("--backup-dir"),
442 OsString::from("backups/run"),
443 ])
444 .expect_err("conflicting sources should fail");
445
446 assert!(matches!(
447 err,
448 RestoreCommandError::ConflictingManifestSources
449 ));
450 }
451
452 #[test]
454 fn parse_rejects_require_verified_with_manifest_source() {
455 let err = RestorePlanOptions::parse([
456 OsString::from("--manifest"),
457 OsString::from("manifest.json"),
458 OsString::from("--require-verified"),
459 ])
460 .expect_err("verification should require a backup layout");
461
462 assert!(matches!(
463 err,
464 RestoreCommandError::RequireVerifiedNeedsBackupDir
465 ));
466 }
467
468 #[test]
470 fn plan_restore_requires_verified_backup_layout() {
471 let root = temp_dir("canic-cli-restore-plan-verified");
472 let layout = BackupLayout::new(root.clone());
473 let manifest = valid_manifest();
474 write_verified_layout(&root, &layout, &manifest);
475
476 let options = RestorePlanOptions {
477 manifest: None,
478 backup_dir: Some(root.clone()),
479 mapping: None,
480 out: None,
481 require_verified: true,
482 require_restore_ready: false,
483 };
484
485 let plan = plan_restore(&options).expect("plan verified restore");
486
487 fs::remove_dir_all(root).expect("remove temp root");
488 assert_eq!(plan.backup_id, "backup-test");
489 assert_eq!(plan.member_count, 2);
490 }
491
492 #[test]
494 fn plan_restore_rejects_unverified_backup_layout() {
495 let root = temp_dir("canic-cli-restore-plan-unverified");
496 let layout = BackupLayout::new(root.clone());
497 layout
498 .write_manifest(&valid_manifest())
499 .expect("write manifest");
500
501 let options = RestorePlanOptions {
502 manifest: None,
503 backup_dir: Some(root.clone()),
504 mapping: None,
505 out: None,
506 require_verified: true,
507 require_restore_ready: false,
508 };
509
510 let err = plan_restore(&options).expect_err("missing journal should fail");
511
512 fs::remove_dir_all(root).expect("remove temp root");
513 assert!(matches!(err, RestoreCommandError::Persistence(_)));
514 }
515
516 #[test]
518 fn plan_restore_reads_manifest_and_mapping() {
519 let root = temp_dir("canic-cli-restore-plan");
520 fs::create_dir_all(&root).expect("create temp root");
521 let manifest_path = root.join("manifest.json");
522 let mapping_path = root.join("mapping.json");
523
524 fs::write(
525 &manifest_path,
526 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
527 )
528 .expect("write manifest");
529 fs::write(
530 &mapping_path,
531 json!({
532 "members": [
533 {"source_canister": ROOT, "target_canister": ROOT},
534 {"source_canister": CHILD, "target_canister": MAPPED_CHILD}
535 ]
536 })
537 .to_string(),
538 )
539 .expect("write mapping");
540
541 let options = RestorePlanOptions {
542 manifest: Some(manifest_path),
543 backup_dir: None,
544 mapping: Some(mapping_path),
545 out: None,
546 require_verified: false,
547 require_restore_ready: false,
548 };
549
550 let plan = plan_restore(&options).expect("plan restore");
551
552 fs::remove_dir_all(root).expect("remove temp root");
553 let members = plan.ordered_members();
554 assert_eq!(members.len(), 2);
555 assert_eq!(members[0].source_canister, ROOT);
556 assert_eq!(members[1].target_canister, MAPPED_CHILD);
557 }
558
559 #[test]
561 fn run_restore_plan_require_restore_ready_writes_plan_then_fails() {
562 let root = temp_dir("canic-cli-restore-plan-require-ready");
563 fs::create_dir_all(&root).expect("create temp root");
564 let manifest_path = root.join("manifest.json");
565 let out_path = root.join("plan.json");
566
567 fs::write(
568 &manifest_path,
569 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
570 )
571 .expect("write manifest");
572
573 let err = run([
574 OsString::from("plan"),
575 OsString::from("--manifest"),
576 OsString::from(manifest_path.as_os_str()),
577 OsString::from("--out"),
578 OsString::from(out_path.as_os_str()),
579 OsString::from("--require-restore-ready"),
580 ])
581 .expect_err("restore readiness should be enforced");
582
583 assert!(out_path.exists());
584 let plan: RestorePlan =
585 serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
586
587 fs::remove_dir_all(root).expect("remove temp root");
588 assert!(!plan.readiness_summary.ready);
589 assert!(matches!(
590 err,
591 RestoreCommandError::RestoreNotReady {
592 reasons,
593 ..
594 } if reasons == [
595 "missing-module-hash",
596 "missing-wasm-hash",
597 "missing-snapshot-checksum"
598 ]
599 ));
600 }
601
602 #[test]
604 fn run_restore_plan_require_restore_ready_accepts_ready_plan() {
605 let root = temp_dir("canic-cli-restore-plan-ready");
606 fs::create_dir_all(&root).expect("create temp root");
607 let manifest_path = root.join("manifest.json");
608 let out_path = root.join("plan.json");
609
610 fs::write(
611 &manifest_path,
612 serde_json::to_vec(&restore_ready_manifest()).expect("serialize manifest"),
613 )
614 .expect("write manifest");
615
616 run([
617 OsString::from("plan"),
618 OsString::from("--manifest"),
619 OsString::from(manifest_path.as_os_str()),
620 OsString::from("--out"),
621 OsString::from(out_path.as_os_str()),
622 OsString::from("--require-restore-ready"),
623 ])
624 .expect("restore-ready plan should pass");
625
626 let plan: RestorePlan =
627 serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
628
629 fs::remove_dir_all(root).expect("remove temp root");
630 assert!(plan.readiness_summary.ready);
631 assert!(plan.readiness_summary.reasons.is_empty());
632 }
633
634 #[test]
636 fn run_restore_status_writes_planned_status() {
637 let root = temp_dir("canic-cli-restore-status");
638 fs::create_dir_all(&root).expect("create temp root");
639 let plan_path = root.join("restore-plan.json");
640 let out_path = root.join("restore-status.json");
641 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
642
643 fs::write(
644 &plan_path,
645 serde_json::to_vec(&plan).expect("serialize plan"),
646 )
647 .expect("write plan");
648
649 run([
650 OsString::from("status"),
651 OsString::from("--plan"),
652 OsString::from(plan_path.as_os_str()),
653 OsString::from("--out"),
654 OsString::from(out_path.as_os_str()),
655 ])
656 .expect("write restore status");
657
658 let status: RestoreStatus =
659 serde_json::from_slice(&fs::read(&out_path).expect("read restore status"))
660 .expect("decode restore status");
661 let status_json: serde_json::Value = serde_json::to_value(&status).expect("encode status");
662
663 fs::remove_dir_all(root).expect("remove temp root");
664 assert_eq!(status.status_version, 1);
665 assert_eq!(status.backup_id.as_str(), "backup-test");
666 assert!(status.ready);
667 assert!(status.readiness_reasons.is_empty());
668 assert_eq!(status.member_count, 2);
669 assert_eq!(status.phase_count, 1);
670 assert_eq!(status.planned_snapshot_loads, 2);
671 assert_eq!(status.planned_code_reinstalls, 2);
672 assert_eq!(status.planned_verification_checks, 2);
673 assert_eq!(status.phases[0].members[0].source_canister, ROOT);
674 assert_eq!(status_json["phases"][0]["members"][0]["state"], "planned");
675 }
676
677 fn valid_manifest() -> FleetBackupManifest {
679 FleetBackupManifest {
680 manifest_version: 1,
681 backup_id: "backup-test".to_string(),
682 created_at: "2026-05-03T00:00:00Z".to_string(),
683 tool: ToolMetadata {
684 name: "canic".to_string(),
685 version: "0.30.1".to_string(),
686 },
687 source: SourceMetadata {
688 environment: "local".to_string(),
689 root_canister: ROOT.to_string(),
690 },
691 consistency: ConsistencySection {
692 mode: ConsistencyMode::CrashConsistent,
693 backup_units: vec![BackupUnit {
694 unit_id: "fleet".to_string(),
695 kind: BackupUnitKind::SubtreeRooted,
696 roles: vec!["root".to_string(), "app".to_string()],
697 consistency_reason: None,
698 dependency_closure: Vec::new(),
699 topology_validation: "subtree-closed".to_string(),
700 quiescence_strategy: None,
701 }],
702 },
703 fleet: FleetSection {
704 topology_hash_algorithm: "sha256".to_string(),
705 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
706 discovery_topology_hash: HASH.to_string(),
707 pre_snapshot_topology_hash: HASH.to_string(),
708 topology_hash: HASH.to_string(),
709 members: vec![
710 fleet_member("root", ROOT, None, IdentityMode::Fixed),
711 fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
712 ],
713 },
714 verification: VerificationPlan::default(),
715 }
716 }
717
718 fn restore_ready_manifest() -> FleetBackupManifest {
720 let mut manifest = valid_manifest();
721 for member in &mut manifest.fleet.members {
722 member.source_snapshot.module_hash = Some(HASH.to_string());
723 member.source_snapshot.wasm_hash = Some(HASH.to_string());
724 member.source_snapshot.checksum = Some(HASH.to_string());
725 }
726 manifest
727 }
728
729 fn fleet_member(
731 role: &str,
732 canister_id: &str,
733 parent_canister_id: Option<&str>,
734 identity_mode: IdentityMode,
735 ) -> FleetMember {
736 FleetMember {
737 role: role.to_string(),
738 canister_id: canister_id.to_string(),
739 parent_canister_id: parent_canister_id.map(str::to_string),
740 subnet_canister_id: Some(ROOT.to_string()),
741 controller_hint: None,
742 identity_mode,
743 restore_group: 1,
744 verification_class: "basic".to_string(),
745 verification_checks: vec![VerificationCheck {
746 kind: "status".to_string(),
747 method: None,
748 roles: vec![role.to_string()],
749 }],
750 source_snapshot: SourceSnapshot {
751 snapshot_id: format!("{role}-snapshot"),
752 module_hash: None,
753 wasm_hash: None,
754 code_version: Some("v0.30.1".to_string()),
755 artifact_path: format!("artifacts/{role}"),
756 checksum_algorithm: "sha256".to_string(),
757 checksum: None,
758 },
759 }
760 }
761
762 fn write_verified_layout(root: &Path, layout: &BackupLayout, manifest: &FleetBackupManifest) {
764 layout.write_manifest(manifest).expect("write manifest");
765
766 let artifacts = manifest
767 .fleet
768 .members
769 .iter()
770 .map(|member| {
771 let bytes = format!("{} artifact", member.role);
772 let artifact_path = root.join(&member.source_snapshot.artifact_path);
773 if let Some(parent) = artifact_path.parent() {
774 fs::create_dir_all(parent).expect("create artifact parent");
775 }
776 fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
777 let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
778
779 ArtifactJournalEntry {
780 canister_id: member.canister_id.clone(),
781 snapshot_id: member.source_snapshot.snapshot_id.clone(),
782 state: ArtifactState::Durable,
783 temp_path: None,
784 artifact_path: member.source_snapshot.artifact_path.clone(),
785 checksum_algorithm: checksum.algorithm,
786 checksum: Some(checksum.hash),
787 updated_at: "2026-05-03T00:00:00Z".to_string(),
788 }
789 })
790 .collect();
791
792 layout
793 .write_journal(&DownloadJournal {
794 journal_version: 1,
795 backup_id: manifest.backup_id.clone(),
796 discovery_topology_hash: Some(manifest.fleet.discovery_topology_hash.clone()),
797 pre_snapshot_topology_hash: Some(manifest.fleet.pre_snapshot_topology_hash.clone()),
798 operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
799 artifacts,
800 })
801 .expect("write journal");
802 }
803
804 fn temp_dir(prefix: &str) -> PathBuf {
806 let nanos = SystemTime::now()
807 .duration_since(UNIX_EPOCH)
808 .expect("system time after epoch")
809 .as_nanos();
810 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
811 }
812}