1use canic_backup::{
2 journal::JournalResumeReport,
3 manifest::FleetBackupManifest,
4 persistence::{BackupIntegrityReport, BackupLayout, PersistenceError},
5 restore::{RestoreMapping, RestorePlanError, RestorePlanner},
6};
7use serde_json::json;
8use std::{
9 ffi::OsString,
10 fs,
11 io::{self, Write},
12 path::PathBuf,
13};
14use thiserror::Error as ThisError;
15
16#[derive(Debug, ThisError)]
21pub enum BackupCommandError {
22 #[error("{0}")]
23 Usage(&'static str),
24
25 #[error("missing required option {0}")]
26 MissingOption(&'static str),
27
28 #[error("unknown option {0}")]
29 UnknownOption(String),
30
31 #[error("option {0} requires a value")]
32 MissingValue(&'static str),
33
34 #[error(
35 "backup journal {backup_id} is incomplete: {pending_artifacts}/{total_artifacts} artifacts still require resume work"
36 )]
37 IncompleteJournal {
38 backup_id: String,
39 total_artifacts: usize,
40 pending_artifacts: usize,
41 },
42
43 #[error(transparent)]
44 Io(#[from] std::io::Error),
45
46 #[error(transparent)]
47 Json(#[from] serde_json::Error),
48
49 #[error(transparent)]
50 Persistence(#[from] PersistenceError),
51
52 #[error(transparent)]
53 RestorePlan(#[from] RestorePlanError),
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct BackupPreflightOptions {
62 pub dir: PathBuf,
63 pub out_dir: PathBuf,
64 pub mapping: Option<PathBuf>,
65}
66
67impl BackupPreflightOptions {
68 pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
70 where
71 I: IntoIterator<Item = OsString>,
72 {
73 let mut dir = None;
74 let mut out_dir = None;
75 let mut mapping = None;
76
77 let mut args = args.into_iter();
78 while let Some(arg) = args.next() {
79 let arg = arg
80 .into_string()
81 .map_err(|_| BackupCommandError::Usage(usage()))?;
82 match arg.as_str() {
83 "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
84 "--out-dir" => out_dir = Some(PathBuf::from(next_value(&mut args, "--out-dir")?)),
85 "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
86 "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
87 _ => return Err(BackupCommandError::UnknownOption(arg)),
88 }
89 }
90
91 Ok(Self {
92 dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
93 out_dir: out_dir.ok_or(BackupCommandError::MissingOption("--out-dir"))?,
94 mapping,
95 })
96 }
97}
98
99#[derive(Clone, Debug, Eq, PartialEq)]
104pub struct BackupPreflightReport {
105 pub status: String,
106 pub backup_id: String,
107 pub backup_dir: String,
108 pub source_environment: String,
109 pub source_root_canister: String,
110 pub topology_hash: String,
111 pub mapping_path: Option<String>,
112 pub journal_complete: bool,
113 pub integrity_verified: bool,
114 pub manifest_members: usize,
115 pub restore_plan_members: usize,
116 pub manifest_validation_path: String,
117 pub backup_status_path: String,
118 pub backup_integrity_path: String,
119 pub restore_plan_path: String,
120 pub preflight_summary_path: String,
121}
122
123#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct BackupVerifyOptions {
129 pub dir: PathBuf,
130 pub out: Option<PathBuf>,
131}
132
133impl BackupVerifyOptions {
134 pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
136 where
137 I: IntoIterator<Item = OsString>,
138 {
139 let mut dir = None;
140 let mut out = None;
141
142 let mut args = args.into_iter();
143 while let Some(arg) = args.next() {
144 let arg = arg
145 .into_string()
146 .map_err(|_| BackupCommandError::Usage(usage()))?;
147 match arg.as_str() {
148 "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
149 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
150 "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
151 _ => return Err(BackupCommandError::UnknownOption(arg)),
152 }
153 }
154
155 Ok(Self {
156 dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
157 out,
158 })
159 }
160}
161
162#[derive(Clone, Debug, Eq, PartialEq)]
167pub struct BackupStatusOptions {
168 pub dir: PathBuf,
169 pub out: Option<PathBuf>,
170 pub require_complete: bool,
171}
172
173impl BackupStatusOptions {
174 pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
176 where
177 I: IntoIterator<Item = OsString>,
178 {
179 let mut dir = None;
180 let mut out = None;
181 let mut require_complete = false;
182
183 let mut args = args.into_iter();
184 while let Some(arg) = args.next() {
185 let arg = arg
186 .into_string()
187 .map_err(|_| BackupCommandError::Usage(usage()))?;
188 match arg.as_str() {
189 "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
190 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
191 "--require-complete" => require_complete = true,
192 "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
193 _ => return Err(BackupCommandError::UnknownOption(arg)),
194 }
195 }
196
197 Ok(Self {
198 dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
199 out,
200 require_complete,
201 })
202 }
203}
204
205pub fn run<I>(args: I) -> Result<(), BackupCommandError>
207where
208 I: IntoIterator<Item = OsString>,
209{
210 let mut args = args.into_iter();
211 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
212 return Err(BackupCommandError::Usage(usage()));
213 };
214
215 match command.as_str() {
216 "preflight" => {
217 let options = BackupPreflightOptions::parse(args)?;
218 backup_preflight(&options)?;
219 Ok(())
220 }
221 "status" => {
222 let options = BackupStatusOptions::parse(args)?;
223 let report = backup_status(&options)?;
224 write_status_report(&options, &report)?;
225 enforce_status_requirements(&options, &report)?;
226 Ok(())
227 }
228 "verify" => {
229 let options = BackupVerifyOptions::parse(args)?;
230 let report = verify_backup(&options)?;
231 write_report(&options, &report)?;
232 Ok(())
233 }
234 "help" | "--help" | "-h" => Err(BackupCommandError::Usage(usage())),
235 _ => Err(BackupCommandError::UnknownOption(command)),
236 }
237}
238
239pub fn backup_preflight(
241 options: &BackupPreflightOptions,
242) -> Result<BackupPreflightReport, BackupCommandError> {
243 fs::create_dir_all(&options.out_dir)?;
244
245 let layout = BackupLayout::new(options.dir.clone());
246 let manifest = layout.read_manifest()?;
247 let status = layout.read_journal()?.resume_report();
248 ensure_complete_status(&status)?;
249 let integrity = layout.verify_integrity()?;
250 let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
251 let restore_plan = RestorePlanner::plan(&manifest, mapping.as_ref())?;
252
253 let manifest_validation_path = options.out_dir.join("manifest-validation.json");
254 let backup_status_path = options.out_dir.join("backup-status.json");
255 let backup_integrity_path = options.out_dir.join("backup-integrity.json");
256 let restore_plan_path = options.out_dir.join("restore-plan.json");
257 let preflight_summary_path = options.out_dir.join("preflight-summary.json");
258
259 write_json_value_file(
260 &manifest_validation_path,
261 &manifest_validation_summary(&manifest),
262 )?;
263 fs::write(&backup_status_path, serde_json::to_vec_pretty(&status)?)?;
264 fs::write(
265 &backup_integrity_path,
266 serde_json::to_vec_pretty(&integrity)?,
267 )?;
268 fs::write(
269 &restore_plan_path,
270 serde_json::to_vec_pretty(&restore_plan)?,
271 )?;
272
273 let report = BackupPreflightReport {
274 status: "ready".to_string(),
275 backup_id: manifest.backup_id.clone(),
276 backup_dir: options.dir.display().to_string(),
277 source_environment: manifest.source.environment.clone(),
278 source_root_canister: manifest.source.root_canister.clone(),
279 topology_hash: manifest.fleet.topology_hash.clone(),
280 mapping_path: options
281 .mapping
282 .as_ref()
283 .map(|path| path.display().to_string()),
284 journal_complete: status.is_complete,
285 integrity_verified: integrity.verified,
286 manifest_members: manifest.fleet.members.len(),
287 restore_plan_members: restore_plan.member_count,
288 manifest_validation_path: manifest_validation_path.display().to_string(),
289 backup_status_path: backup_status_path.display().to_string(),
290 backup_integrity_path: backup_integrity_path.display().to_string(),
291 restore_plan_path: restore_plan_path.display().to_string(),
292 preflight_summary_path: preflight_summary_path.display().to_string(),
293 };
294
295 write_json_value_file(&preflight_summary_path, &preflight_summary_value(&report))?;
296 Ok(report)
297}
298
299pub fn backup_status(
301 options: &BackupStatusOptions,
302) -> Result<JournalResumeReport, BackupCommandError> {
303 let layout = BackupLayout::new(options.dir.clone());
304 let journal = layout.read_journal()?;
305 Ok(journal.resume_report())
306}
307
308fn ensure_complete_status(report: &JournalResumeReport) -> Result<(), BackupCommandError> {
310 if report.is_complete {
311 return Ok(());
312 }
313
314 Err(BackupCommandError::IncompleteJournal {
315 backup_id: report.backup_id.clone(),
316 total_artifacts: report.total_artifacts,
317 pending_artifacts: report.pending_artifacts,
318 })
319}
320
321fn enforce_status_requirements(
323 options: &BackupStatusOptions,
324 report: &JournalResumeReport,
325) -> Result<(), BackupCommandError> {
326 if !options.require_complete {
327 return Ok(());
328 }
329
330 ensure_complete_status(report)
331}
332
333pub fn verify_backup(
335 options: &BackupVerifyOptions,
336) -> Result<BackupIntegrityReport, BackupCommandError> {
337 let layout = BackupLayout::new(options.dir.clone());
338 layout.verify_integrity().map_err(BackupCommandError::from)
339}
340
341fn write_status_report(
343 options: &BackupStatusOptions,
344 report: &JournalResumeReport,
345) -> Result<(), BackupCommandError> {
346 if let Some(path) = &options.out {
347 let data = serde_json::to_vec_pretty(report)?;
348 fs::write(path, data)?;
349 return Ok(());
350 }
351
352 let stdout = io::stdout();
353 let mut handle = stdout.lock();
354 serde_json::to_writer_pretty(&mut handle, report)?;
355 writeln!(handle)?;
356 Ok(())
357}
358
359fn write_report(
361 options: &BackupVerifyOptions,
362 report: &BackupIntegrityReport,
363) -> Result<(), BackupCommandError> {
364 if let Some(path) = &options.out {
365 let data = serde_json::to_vec_pretty(report)?;
366 fs::write(path, data)?;
367 return Ok(());
368 }
369
370 let stdout = io::stdout();
371 let mut handle = stdout.lock();
372 serde_json::to_writer_pretty(&mut handle, report)?;
373 writeln!(handle)?;
374 Ok(())
375}
376
377fn write_json_value_file(
379 path: &PathBuf,
380 value: &serde_json::Value,
381) -> Result<(), BackupCommandError> {
382 if let Some(parent) = path.parent() {
383 fs::create_dir_all(parent)?;
384 }
385
386 let data = serde_json::to_vec_pretty(value)?;
387 fs::write(path, data)?;
388 Ok(())
389}
390
391fn preflight_summary_value(report: &BackupPreflightReport) -> serde_json::Value {
393 json!({
394 "status": report.status,
395 "backup_id": report.backup_id,
396 "backup_dir": report.backup_dir,
397 "source_environment": report.source_environment,
398 "source_root_canister": report.source_root_canister,
399 "topology_hash": report.topology_hash,
400 "mapping_path": report.mapping_path,
401 "journal_complete": report.journal_complete,
402 "integrity_verified": report.integrity_verified,
403 "manifest_members": report.manifest_members,
404 "restore_plan_members": report.restore_plan_members,
405 "manifest_validation_path": report.manifest_validation_path,
406 "backup_status_path": report.backup_status_path,
407 "backup_integrity_path": report.backup_integrity_path,
408 "restore_plan_path": report.restore_plan_path,
409 "preflight_summary_path": report.preflight_summary_path,
410 })
411}
412
413fn manifest_validation_summary(manifest: &FleetBackupManifest) -> serde_json::Value {
415 json!({
416 "status": "valid",
417 "backup_id": manifest.backup_id,
418 "members": manifest.fleet.members.len(),
419 "topology_hash": manifest.fleet.topology_hash,
420 })
421}
422
423fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, BackupCommandError> {
425 let data = fs::read_to_string(path)?;
426 serde_json::from_str(&data).map_err(BackupCommandError::from)
427}
428
429fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, BackupCommandError>
431where
432 I: Iterator<Item = OsString>,
433{
434 args.next()
435 .and_then(|value| value.into_string().ok())
436 .ok_or(BackupCommandError::MissingValue(option))
437}
438
439const fn usage() -> &'static str {
441 "usage: canic backup preflight --dir <backup-dir> --out-dir <dir> [--mapping <file>]\n canic backup status --dir <backup-dir> [--out <file>] [--require-complete]\n canic backup verify --dir <backup-dir> [--out <file>]"
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use canic_backup::{
448 artifacts::ArtifactChecksum,
449 journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
450 manifest::{
451 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetBackupManifest,
452 FleetMember, FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
453 VerificationCheck, VerificationPlan,
454 },
455 };
456 use std::{
457 fs,
458 path::Path,
459 time::{SystemTime, UNIX_EPOCH},
460 };
461
462 const ROOT: &str = "aaaaa-aa";
463 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
464
465 #[test]
467 fn parses_backup_preflight_options() {
468 let options = BackupPreflightOptions::parse([
469 OsString::from("--dir"),
470 OsString::from("backups/run"),
471 OsString::from("--out-dir"),
472 OsString::from("reports/run"),
473 OsString::from("--mapping"),
474 OsString::from("mapping.json"),
475 ])
476 .expect("parse options");
477
478 assert_eq!(options.dir, PathBuf::from("backups/run"));
479 assert_eq!(options.out_dir, PathBuf::from("reports/run"));
480 assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
481 }
482
483 #[test]
485 fn backup_preflight_writes_standard_reports() {
486 let root = temp_dir("canic-cli-backup-preflight");
487 let out_dir = root.join("reports");
488 let backup_dir = root.join("backup");
489 let layout = BackupLayout::new(backup_dir.clone());
490 let checksum = write_artifact(&backup_dir, b"root artifact");
491
492 layout
493 .write_manifest(&valid_manifest())
494 .expect("write manifest");
495 layout
496 .write_journal(&journal_with_checksum(checksum.hash))
497 .expect("write journal");
498
499 let options = BackupPreflightOptions {
500 dir: backup_dir,
501 out_dir: out_dir.clone(),
502 mapping: None,
503 };
504 let report = backup_preflight(&options).expect("run preflight");
505
506 assert_eq!(report.status, "ready");
507 assert_eq!(report.backup_id, "backup-test");
508 assert_eq!(report.source_environment, "local");
509 assert_eq!(report.source_root_canister, ROOT);
510 assert_eq!(report.topology_hash, HASH);
511 assert_eq!(report.mapping_path, None);
512 assert!(report.journal_complete);
513 assert!(report.integrity_verified);
514 assert_eq!(report.manifest_members, 1);
515 assert_eq!(report.restore_plan_members, 1);
516 assert!(out_dir.join("manifest-validation.json").exists());
517 assert!(out_dir.join("backup-status.json").exists());
518 assert!(out_dir.join("backup-integrity.json").exists());
519 assert!(out_dir.join("restore-plan.json").exists());
520 assert!(out_dir.join("preflight-summary.json").exists());
521
522 let summary: serde_json::Value = serde_json::from_slice(
523 &fs::read(out_dir.join("preflight-summary.json")).expect("read summary"),
524 )
525 .expect("decode summary");
526
527 fs::remove_dir_all(root).expect("remove temp root");
528 assert_eq!(summary["status"], report.status);
529 assert_eq!(summary["backup_id"], report.backup_id);
530 assert_eq!(summary["source_environment"], report.source_environment);
531 assert_eq!(summary["source_root_canister"], report.source_root_canister);
532 assert_eq!(summary["topology_hash"], report.topology_hash);
533 assert_eq!(summary["journal_complete"], report.journal_complete);
534 assert_eq!(summary["integrity_verified"], report.integrity_verified);
535 assert_eq!(summary["manifest_members"], report.manifest_members);
536 assert_eq!(summary["restore_plan_members"], report.restore_plan_members);
537 }
538
539 #[test]
541 fn backup_preflight_rejects_incomplete_journal() {
542 let root = temp_dir("canic-cli-backup-preflight-incomplete");
543 let out_dir = root.join("reports");
544 let backup_dir = root.join("backup");
545 let layout = BackupLayout::new(backup_dir.clone());
546
547 layout
548 .write_manifest(&valid_manifest())
549 .expect("write manifest");
550 layout
551 .write_journal(&created_journal())
552 .expect("write journal");
553
554 let options = BackupPreflightOptions {
555 dir: backup_dir,
556 out_dir,
557 mapping: None,
558 };
559
560 let err = backup_preflight(&options).expect_err("incomplete journal should fail");
561
562 fs::remove_dir_all(root).expect("remove temp root");
563 assert!(matches!(
564 err,
565 BackupCommandError::IncompleteJournal {
566 pending_artifacts: 1,
567 total_artifacts: 1,
568 ..
569 }
570 ));
571 }
572
573 #[test]
575 fn parses_backup_verify_options() {
576 let options = BackupVerifyOptions::parse([
577 OsString::from("--dir"),
578 OsString::from("backups/run"),
579 OsString::from("--out"),
580 OsString::from("report.json"),
581 ])
582 .expect("parse options");
583
584 assert_eq!(options.dir, PathBuf::from("backups/run"));
585 assert_eq!(options.out, Some(PathBuf::from("report.json")));
586 }
587
588 #[test]
590 fn parses_backup_status_options() {
591 let options = BackupStatusOptions::parse([
592 OsString::from("--dir"),
593 OsString::from("backups/run"),
594 OsString::from("--out"),
595 OsString::from("status.json"),
596 OsString::from("--require-complete"),
597 ])
598 .expect("parse options");
599
600 assert_eq!(options.dir, PathBuf::from("backups/run"));
601 assert_eq!(options.out, Some(PathBuf::from("status.json")));
602 assert!(options.require_complete);
603 }
604
605 #[test]
607 fn backup_status_reads_journal_resume_report() {
608 let root = temp_dir("canic-cli-backup-status");
609 let layout = BackupLayout::new(root.clone());
610 layout
611 .write_journal(&journal_with_checksum(HASH.to_string()))
612 .expect("write journal");
613
614 let options = BackupStatusOptions {
615 dir: root.clone(),
616 out: None,
617 require_complete: false,
618 };
619 let report = backup_status(&options).expect("read backup status");
620
621 fs::remove_dir_all(root).expect("remove temp root");
622 assert_eq!(report.backup_id, "backup-test");
623 assert_eq!(report.total_artifacts, 1);
624 assert!(report.is_complete);
625 assert_eq!(report.pending_artifacts, 0);
626 assert_eq!(report.counts.skip, 1);
627 }
628
629 #[test]
631 fn require_complete_accepts_complete_status() {
632 let options = BackupStatusOptions {
633 dir: PathBuf::from("unused"),
634 out: None,
635 require_complete: true,
636 };
637 let report = journal_with_checksum(HASH.to_string()).resume_report();
638
639 enforce_status_requirements(&options, &report).expect("complete status should pass");
640 }
641
642 #[test]
644 fn require_complete_rejects_incomplete_status() {
645 let options = BackupStatusOptions {
646 dir: PathBuf::from("unused"),
647 out: None,
648 require_complete: true,
649 };
650 let report = created_journal().resume_report();
651
652 let err = enforce_status_requirements(&options, &report)
653 .expect_err("incomplete status should fail");
654
655 assert!(matches!(
656 err,
657 BackupCommandError::IncompleteJournal {
658 pending_artifacts: 1,
659 total_artifacts: 1,
660 ..
661 }
662 ));
663 }
664
665 #[test]
667 fn verify_backup_reads_layout_and_artifacts() {
668 let root = temp_dir("canic-cli-backup-verify");
669 let layout = BackupLayout::new(root.clone());
670 let checksum = write_artifact(&root, b"root artifact");
671
672 layout
673 .write_manifest(&valid_manifest())
674 .expect("write manifest");
675 layout
676 .write_journal(&journal_with_checksum(checksum.hash.clone()))
677 .expect("write journal");
678
679 let options = BackupVerifyOptions {
680 dir: root.clone(),
681 out: None,
682 };
683 let report = verify_backup(&options).expect("verify backup");
684
685 fs::remove_dir_all(root).expect("remove temp root");
686 assert_eq!(report.backup_id, "backup-test");
687 assert!(report.verified);
688 assert_eq!(report.durable_artifacts, 1);
689 assert_eq!(report.artifacts[0].checksum, checksum.hash);
690 }
691
692 fn valid_manifest() -> FleetBackupManifest {
694 FleetBackupManifest {
695 manifest_version: 1,
696 backup_id: "backup-test".to_string(),
697 created_at: "2026-05-03T00:00:00Z".to_string(),
698 tool: ToolMetadata {
699 name: "canic".to_string(),
700 version: "0.30.3".to_string(),
701 },
702 source: SourceMetadata {
703 environment: "local".to_string(),
704 root_canister: ROOT.to_string(),
705 },
706 consistency: ConsistencySection {
707 mode: ConsistencyMode::CrashConsistent,
708 backup_units: vec![BackupUnit {
709 unit_id: "fleet".to_string(),
710 kind: BackupUnitKind::SubtreeRooted,
711 roles: vec!["root".to_string()],
712 consistency_reason: None,
713 dependency_closure: Vec::new(),
714 topology_validation: "subtree-closed".to_string(),
715 quiescence_strategy: None,
716 }],
717 },
718 fleet: FleetSection {
719 topology_hash_algorithm: "sha256".to_string(),
720 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
721 discovery_topology_hash: HASH.to_string(),
722 pre_snapshot_topology_hash: HASH.to_string(),
723 topology_hash: HASH.to_string(),
724 members: vec![fleet_member()],
725 },
726 verification: VerificationPlan::default(),
727 }
728 }
729
730 fn fleet_member() -> FleetMember {
732 FleetMember {
733 role: "root".to_string(),
734 canister_id: ROOT.to_string(),
735 parent_canister_id: None,
736 subnet_canister_id: Some(ROOT.to_string()),
737 controller_hint: None,
738 identity_mode: IdentityMode::Fixed,
739 restore_group: 1,
740 verification_class: "basic".to_string(),
741 verification_checks: vec![VerificationCheck {
742 kind: "status".to_string(),
743 method: None,
744 roles: vec!["root".to_string()],
745 }],
746 source_snapshot: SourceSnapshot {
747 snapshot_id: "root-snapshot".to_string(),
748 module_hash: None,
749 wasm_hash: None,
750 code_version: Some("v0.30.3".to_string()),
751 artifact_path: "artifacts/root".to_string(),
752 checksum_algorithm: "sha256".to_string(),
753 checksum: None,
754 },
755 }
756 }
757
758 fn journal_with_checksum(checksum: String) -> DownloadJournal {
760 DownloadJournal {
761 journal_version: 1,
762 backup_id: "backup-test".to_string(),
763 artifacts: vec![ArtifactJournalEntry {
764 canister_id: ROOT.to_string(),
765 snapshot_id: "root-snapshot".to_string(),
766 state: ArtifactState::Durable,
767 temp_path: None,
768 artifact_path: "artifacts/root".to_string(),
769 checksum_algorithm: "sha256".to_string(),
770 checksum: Some(checksum),
771 updated_at: "2026-05-03T00:00:00Z".to_string(),
772 }],
773 }
774 }
775
776 fn created_journal() -> DownloadJournal {
778 DownloadJournal {
779 journal_version: 1,
780 backup_id: "backup-test".to_string(),
781 artifacts: vec![ArtifactJournalEntry {
782 canister_id: ROOT.to_string(),
783 snapshot_id: "root-snapshot".to_string(),
784 state: ArtifactState::Created,
785 temp_path: None,
786 artifact_path: "artifacts/root".to_string(),
787 checksum_algorithm: "sha256".to_string(),
788 checksum: None,
789 updated_at: "2026-05-03T00:00:00Z".to_string(),
790 }],
791 }
792 }
793
794 fn write_artifact(root: &Path, bytes: &[u8]) -> ArtifactChecksum {
796 let path = root.join("artifacts/root");
797 fs::create_dir_all(path.parent().expect("artifact has parent")).expect("create artifacts");
798 fs::write(&path, bytes).expect("write artifact");
799 ArtifactChecksum::from_bytes(bytes)
800 }
801
802 fn temp_dir(prefix: &str) -> PathBuf {
804 let nanos = SystemTime::now()
805 .duration_since(UNIX_EPOCH)
806 .expect("system time after epoch")
807 .as_nanos();
808 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
809 }
810}