1use candid::Principal;
2use canic_backup::{
3 artifacts::{ArtifactChecksum, ArtifactChecksumError},
4 journal::JournalValidationError,
5 journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
6 manifest::{
7 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetBackupManifest,
8 FleetMember, FleetSection, IdentityMode, ManifestValidationError, SourceMetadata,
9 SourceSnapshot, ToolMetadata, VerificationCheck, VerificationPlan,
10 },
11 persistence::{BackupLayout, PersistenceError},
12 topology::{TopologyHasher, TopologyRecord},
13};
14use serde_json::Value;
15use std::{
16 collections::{BTreeMap, BTreeSet, VecDeque},
17 ffi::OsString,
18 fs,
19 path::{Path, PathBuf},
20 process::Command,
21};
22use thiserror::Error as ThisError;
23
24#[derive(Debug, ThisError)]
29pub enum SnapshotCommandError {
30 #[error("{0}")]
31 Usage(&'static str),
32
33 #[error("missing required option {0}")]
34 MissingOption(&'static str),
35
36 #[error("unknown option {0}")]
37 UnknownOption(String),
38
39 #[error("option {0} requires a value")]
40 MissingValue(&'static str),
41
42 #[error("cannot combine --root and --registry-json")]
43 ConflictingRegistrySources,
44
45 #[error("registry JSON did not contain the requested canister {0}")]
46 CanisterNotInRegistry(String),
47
48 #[error("dfx command failed: {command}\n{stderr}")]
49 DfxFailed { command: String, stderr: String },
50
51 #[error("could not parse snapshot id from dfx output: {0}")]
52 SnapshotIdUnavailable(String),
53
54 #[error("field {field} must be a valid principal: {value}")]
55 InvalidPrincipal { field: &'static str, value: String },
56
57 #[error(transparent)]
58 Io(#[from] std::io::Error),
59
60 #[error(transparent)]
61 Json(#[from] serde_json::Error),
62
63 #[error(transparent)]
64 Checksum(#[from] ArtifactChecksumError),
65
66 #[error(transparent)]
67 Persistence(#[from] PersistenceError),
68
69 #[error(transparent)]
70 Journal(#[from] JournalValidationError),
71
72 #[error(transparent)]
73 InvalidManifest(#[from] ManifestValidationError),
74}
75
76#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct SnapshotDownloadOptions {
82 pub canister: String,
83 pub out: PathBuf,
84 pub root: Option<String>,
85 pub registry_json: Option<PathBuf>,
86 pub include_children: bool,
87 pub recursive: bool,
88 pub dry_run: bool,
89 pub lifecycle: SnapshotLifecycleMode,
90 pub network: Option<String>,
91 pub dfx: String,
92}
93
94impl SnapshotDownloadOptions {
95 pub fn parse<I>(args: I) -> Result<Self, SnapshotCommandError>
97 where
98 I: IntoIterator<Item = OsString>,
99 {
100 let mut canister = None;
101 let mut out = None;
102 let mut root = None;
103 let mut registry_json = None;
104 let mut include_children = false;
105 let mut recursive = false;
106 let mut dry_run = false;
107 let mut stop_before_snapshot = false;
108 let mut resume_after_snapshot = false;
109 let mut network = None;
110 let mut dfx = "dfx".to_string();
111
112 let mut args = args.into_iter();
113 while let Some(arg) = args.next() {
114 let arg = arg
115 .into_string()
116 .map_err(|_| SnapshotCommandError::Usage(usage()))?;
117 match arg.as_str() {
118 "--canister" => canister = Some(next_value(&mut args, "--canister")?),
119 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
120 "--root" => root = Some(next_value(&mut args, "--root")?),
121 "--registry-json" => {
122 registry_json = Some(PathBuf::from(next_value(&mut args, "--registry-json")?));
123 }
124 "--include-children" => include_children = true,
125 "--recursive" => {
126 recursive = true;
127 include_children = true;
128 }
129 "--dry-run" => dry_run = true,
130 "--stop-before-snapshot" => stop_before_snapshot = true,
131 "--resume-after-snapshot" => resume_after_snapshot = true,
132 "--network" => network = Some(next_value(&mut args, "--network")?),
133 "--dfx" => dfx = next_value(&mut args, "--dfx")?,
134 "--help" | "-h" => return Err(SnapshotCommandError::Usage(usage())),
135 _ => return Err(SnapshotCommandError::UnknownOption(arg)),
136 }
137 }
138
139 if root.is_some() && registry_json.is_some() {
140 return Err(SnapshotCommandError::ConflictingRegistrySources);
141 }
142
143 Ok(Self {
144 canister: canister.ok_or(SnapshotCommandError::MissingOption("--canister"))?,
145 out: out.ok_or(SnapshotCommandError::MissingOption("--out"))?,
146 root,
147 registry_json,
148 include_children,
149 recursive,
150 dry_run,
151 lifecycle: SnapshotLifecycleMode::from_flags(
152 stop_before_snapshot,
153 resume_after_snapshot,
154 ),
155 network,
156 dfx,
157 })
158 }
159}
160
161#[derive(Clone, Copy, Debug, Eq, PartialEq)]
166pub enum SnapshotLifecycleMode {
167 SnapshotOnly,
168 StopBeforeSnapshot,
169 ResumeAfterSnapshot,
170 StopAndResume,
171}
172
173impl SnapshotLifecycleMode {
174 #[must_use]
176 pub const fn from_flags(stop_before_snapshot: bool, resume_after_snapshot: bool) -> Self {
177 match (stop_before_snapshot, resume_after_snapshot) {
178 (false, false) => Self::SnapshotOnly,
179 (true, false) => Self::StopBeforeSnapshot,
180 (false, true) => Self::ResumeAfterSnapshot,
181 (true, true) => Self::StopAndResume,
182 }
183 }
184
185 #[must_use]
187 pub const fn stop_before_snapshot(self) -> bool {
188 matches!(self, Self::StopBeforeSnapshot | Self::StopAndResume)
189 }
190
191 #[must_use]
193 pub const fn resume_after_snapshot(self) -> bool {
194 matches!(self, Self::ResumeAfterSnapshot | Self::StopAndResume)
195 }
196}
197
198#[derive(Clone, Debug, Eq, PartialEq)]
203pub struct SnapshotTarget {
204 pub canister_id: String,
205 pub role: Option<String>,
206 pub parent_canister_id: Option<String>,
207}
208
209pub fn run<I>(args: I) -> Result<(), SnapshotCommandError>
211where
212 I: IntoIterator<Item = OsString>,
213{
214 let mut args = args.into_iter();
215 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
216 return Err(SnapshotCommandError::Usage(usage()));
217 };
218
219 match command.as_str() {
220 "download" => {
221 let options = SnapshotDownloadOptions::parse(args)?;
222 let result = download_snapshots(&options)?;
223 for artifact in result.artifacts {
224 println!(
225 "{} {} {}",
226 artifact.canister_id,
227 artifact.snapshot_id,
228 artifact.path.display()
229 );
230 }
231 Ok(())
232 }
233 "help" | "--help" | "-h" => Err(SnapshotCommandError::Usage(usage())),
234 _ => Err(SnapshotCommandError::UnknownOption(command)),
235 }
236}
237
238#[derive(Clone, Debug, Eq, PartialEq)]
243pub struct SnapshotDownloadResult {
244 pub artifacts: Vec<SnapshotArtifact>,
245}
246
247#[derive(Clone, Debug, Eq, PartialEq)]
252pub struct SnapshotArtifact {
253 pub canister_id: String,
254 pub snapshot_id: String,
255 pub path: PathBuf,
256 pub checksum: String,
257}
258
259pub fn download_snapshots(
261 options: &SnapshotDownloadOptions,
262) -> Result<SnapshotDownloadResult, SnapshotCommandError> {
263 let targets = resolve_targets(options)?;
264 let mut artifacts = Vec::with_capacity(targets.len());
265 let mut journal = DownloadJournal {
266 journal_version: 1,
267 backup_id: backup_id(options),
268 artifacts: Vec::new(),
269 };
270 let layout = BackupLayout::new(options.out.clone());
271
272 for target in &targets {
273 let artifact_relative_path = PathBuf::from(safe_path_segment(&target.canister_id));
274 let artifact_path = options.out.join(&artifact_relative_path);
275 let temp_path = options
276 .out
277 .join(format!("{}.tmp", safe_path_segment(&target.canister_id)));
278
279 if options.dry_run {
280 if options.lifecycle.stop_before_snapshot() {
281 println!(
282 "{}",
283 stop_canister_command_display(options, &target.canister_id)
284 );
285 }
286 println!(
287 "{}",
288 create_snapshot_command_display(options, &target.canister_id)
289 );
290 println!(
291 "{}",
292 download_snapshot_command_display(options, &target.canister_id, "<snapshot-id>")
293 );
294 artifacts.push(SnapshotArtifact {
295 canister_id: target.canister_id.clone(),
296 snapshot_id: "<snapshot-id>".to_string(),
297 path: artifact_path,
298 checksum: "<sha256>".to_string(),
299 });
300 if options.lifecycle.resume_after_snapshot() {
301 println!(
302 "{}",
303 start_canister_command_display(options, &target.canister_id)
304 );
305 }
306 continue;
307 }
308
309 let artifact = with_optional_stop(options, &target.canister_id, || {
310 let snapshot_id = create_snapshot(options, &target.canister_id)?;
311 let mut entry = ArtifactJournalEntry {
312 canister_id: target.canister_id.clone(),
313 snapshot_id: snapshot_id.clone(),
314 state: ArtifactState::Created,
315 temp_path: None,
316 artifact_path: artifact_relative_path.display().to_string(),
317 checksum_algorithm: "sha256".to_string(),
318 checksum: None,
319 updated_at: timestamp_placeholder(),
320 };
321 journal.artifacts.push(entry.clone());
322 layout.write_journal(&journal)?;
323
324 if temp_path.exists() {
325 fs::remove_dir_all(&temp_path)?;
326 }
327 fs::create_dir_all(&temp_path)?;
328 download_snapshot(options, &target.canister_id, &snapshot_id, &temp_path)?;
329 entry.advance_to(ArtifactState::Downloaded, timestamp_placeholder())?;
330 entry.temp_path = Some(temp_path.display().to_string());
331 update_journal_entry(&mut journal, &entry);
332 layout.write_journal(&journal)?;
333
334 let checksum = ArtifactChecksum::from_path(&temp_path)?;
335 entry.checksum = Some(checksum.hash.clone());
336 entry.advance_to(ArtifactState::ChecksumVerified, timestamp_placeholder())?;
337 update_journal_entry(&mut journal, &entry);
338 layout.write_journal(&journal)?;
339
340 if artifact_path.exists() {
341 return Err(std::io::Error::new(
342 std::io::ErrorKind::AlreadyExists,
343 format!("artifact path already exists: {}", artifact_path.display()),
344 )
345 .into());
346 }
347 fs::rename(&temp_path, &artifact_path)?;
348 entry.temp_path = None;
349 entry.advance_to(ArtifactState::Durable, timestamp_placeholder())?;
350 update_journal_entry(&mut journal, &entry);
351 layout.write_journal(&journal)?;
352
353 Ok(SnapshotArtifact {
354 canister_id: target.canister_id.clone(),
355 snapshot_id,
356 path: artifact_path,
357 checksum: checksum.hash,
358 })
359 })?;
360
361 artifacts.push(artifact);
362 }
363
364 if !options.dry_run {
365 let manifest = build_manifest(options, &targets, &artifacts)?;
366 layout.write_manifest(&manifest)?;
367 }
368
369 Ok(SnapshotDownloadResult { artifacts })
370}
371
372fn update_journal_entry(journal: &mut DownloadJournal, entry: &ArtifactJournalEntry) {
374 if let Some(existing) = journal.artifacts.iter_mut().find(|existing| {
375 existing.canister_id == entry.canister_id && existing.snapshot_id == entry.snapshot_id
376 }) {
377 *existing = entry.clone();
378 }
379}
380
381pub fn resolve_targets(
383 options: &SnapshotDownloadOptions,
384) -> Result<Vec<SnapshotTarget>, SnapshotCommandError> {
385 if !options.include_children {
386 return Ok(vec![SnapshotTarget {
387 canister_id: options.canister.clone(),
388 role: None,
389 parent_canister_id: None,
390 }]);
391 }
392
393 let registry = load_registry_entries(options)?;
394 targets_from_registry(®istry, &options.canister, options.recursive)
395}
396
397fn load_registry_entries(
399 options: &SnapshotDownloadOptions,
400) -> Result<Vec<RegistryEntry>, SnapshotCommandError> {
401 let registry_json = if let Some(path) = &options.registry_json {
402 fs::read_to_string(path)?
403 } else if let Some(root) = &options.root {
404 call_subnet_registry(options, root)?
405 } else {
406 return Err(SnapshotCommandError::MissingOption(
407 "--root or --registry-json when using --include-children",
408 ));
409 };
410
411 parse_registry_entries(®istry_json)
412}
413
414fn call_subnet_registry(
416 options: &SnapshotDownloadOptions,
417 root: &str,
418) -> Result<String, SnapshotCommandError> {
419 let mut command = Command::new(&options.dfx);
420 command.arg("canister");
421 add_canister_network_args(&mut command, options);
422 command.args(["call", root, "canic_subnet_registry", "--output", "json"]);
423 run_output(&mut command)
424}
425
426fn create_snapshot(
428 options: &SnapshotDownloadOptions,
429 canister_id: &str,
430) -> Result<String, SnapshotCommandError> {
431 let before = list_snapshot_ids(options, canister_id)?;
432 let mut command = Command::new(&options.dfx);
433 command.arg("canister");
434 add_canister_network_args(&mut command, options);
435 command.args(["snapshot", "create", canister_id]);
436 let output = run_output_with_stderr(&mut command)?;
437 if let Some(snapshot_id) = parse_snapshot_id(&output) {
438 return Ok(snapshot_id);
439 }
440
441 let before = before.into_iter().collect::<BTreeSet<_>>();
442 let mut new_ids = list_snapshot_ids(options, canister_id)?
443 .into_iter()
444 .filter(|snapshot_id| !before.contains(snapshot_id))
445 .collect::<Vec<_>>();
446 if new_ids.len() == 1 {
447 Ok(new_ids.remove(0))
448 } else {
449 Err(SnapshotCommandError::SnapshotIdUnavailable(output))
450 }
451}
452
453fn list_snapshot_ids(
455 options: &SnapshotDownloadOptions,
456 canister_id: &str,
457) -> Result<Vec<String>, SnapshotCommandError> {
458 let mut command = Command::new(&options.dfx);
459 command.arg("canister");
460 add_canister_network_args(&mut command, options);
461 command.args(["snapshot", "list", canister_id]);
462 let output = run_output(&mut command)?;
463 Ok(parse_snapshot_list_ids(&output))
464}
465
466fn stop_canister(
468 options: &SnapshotDownloadOptions,
469 canister_id: &str,
470) -> Result<(), SnapshotCommandError> {
471 let mut command = Command::new(&options.dfx);
472 command.arg("canister");
473 add_canister_network_args(&mut command, options);
474 command.args(["stop", canister_id]);
475 run_status(&mut command)
476}
477
478fn start_canister(
480 options: &SnapshotDownloadOptions,
481 canister_id: &str,
482) -> Result<(), SnapshotCommandError> {
483 let mut command = Command::new(&options.dfx);
484 command.arg("canister");
485 add_canister_network_args(&mut command, options);
486 command.args(["start", canister_id]);
487 run_status(&mut command)
488}
489
490fn with_optional_stop<T>(
492 options: &SnapshotDownloadOptions,
493 canister_id: &str,
494 operation: impl FnOnce() -> Result<T, SnapshotCommandError>,
495) -> Result<T, SnapshotCommandError> {
496 if options.lifecycle.stop_before_snapshot() {
497 stop_canister(options, canister_id)?;
498 }
499
500 let result = operation();
501
502 if options.lifecycle.resume_after_snapshot() {
503 match result {
504 Ok(value) => {
505 start_canister(options, canister_id)?;
506 Ok(value)
507 }
508 Err(error) => {
509 let _ = start_canister(options, canister_id);
510 Err(error)
511 }
512 }
513 } else {
514 result
515 }
516}
517
518fn download_snapshot(
520 options: &SnapshotDownloadOptions,
521 canister_id: &str,
522 snapshot_id: &str,
523 artifact_path: &Path,
524) -> Result<(), SnapshotCommandError> {
525 let mut command = Command::new(&options.dfx);
526 command.arg("canister");
527 add_canister_network_args(&mut command, options);
528 command.args(["snapshot", "download", canister_id, snapshot_id, "--dir"]);
529 command.arg(artifact_path);
530 run_status(&mut command)
531}
532
533fn add_canister_network_args(command: &mut Command, options: &SnapshotDownloadOptions) {
535 if let Some(network) = &options.network {
536 command.args(["--network", network]);
537 }
538}
539
540fn run_output(command: &mut Command) -> Result<String, SnapshotCommandError> {
542 let display = command_display(command);
543 let output = command.output()?;
544 if output.status.success() {
545 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
546 } else {
547 Err(SnapshotCommandError::DfxFailed {
548 command: display,
549 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
550 })
551 }
552}
553
554fn run_output_with_stderr(command: &mut Command) -> Result<String, SnapshotCommandError> {
556 let display = command_display(command);
557 let output = command.output()?;
558 if output.status.success() {
559 let mut text = String::from_utf8_lossy(&output.stdout).to_string();
560 text.push_str(&String::from_utf8_lossy(&output.stderr));
561 Ok(text.trim().to_string())
562 } else {
563 Err(SnapshotCommandError::DfxFailed {
564 command: display,
565 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
566 })
567 }
568}
569
570fn run_status(command: &mut Command) -> Result<(), SnapshotCommandError> {
572 let display = command_display(command);
573 let output = command.output()?;
574 if output.status.success() {
575 Ok(())
576 } else {
577 Err(SnapshotCommandError::DfxFailed {
578 command: display,
579 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
580 })
581 }
582}
583
584fn command_display(command: &Command) -> String {
586 let mut parts = vec![command.get_program().to_string_lossy().to_string()];
587 parts.extend(
588 command
589 .get_args()
590 .map(|arg| arg.to_string_lossy().to_string()),
591 );
592 parts.join(" ")
593}
594
595fn create_snapshot_command_display(options: &SnapshotDownloadOptions, canister_id: &str) -> String {
597 let mut command = Command::new(&options.dfx);
598 command.arg("canister");
599 add_canister_network_args(&mut command, options);
600 command.args(["snapshot", "create", canister_id]);
601 command_display(&command)
602}
603
604fn download_snapshot_command_display(
606 options: &SnapshotDownloadOptions,
607 canister_id: &str,
608 snapshot_id: &str,
609) -> String {
610 let mut command = Command::new(&options.dfx);
611 command.arg("canister");
612 add_canister_network_args(&mut command, options);
613 command.args(["snapshot", "download", canister_id, snapshot_id, "--dir"]);
614 command.arg(options.out.join(safe_path_segment(canister_id)));
615 command_display(&command)
616}
617
618fn stop_canister_command_display(options: &SnapshotDownloadOptions, canister_id: &str) -> String {
620 let mut command = Command::new(&options.dfx);
621 command.arg("canister");
622 add_canister_network_args(&mut command, options);
623 command.args(["stop", canister_id]);
624 command_display(&command)
625}
626
627fn start_canister_command_display(options: &SnapshotDownloadOptions, canister_id: &str) -> String {
629 let mut command = Command::new(&options.dfx);
630 command.arg("canister");
631 add_canister_network_args(&mut command, options);
632 command.args(["start", canister_id]);
633 command_display(&command)
634}
635
636#[derive(Clone, Debug, Eq, PartialEq)]
641pub struct RegistryEntry {
642 pub pid: String,
643 pub role: Option<String>,
644 pub parent_pid: Option<String>,
645}
646
647pub fn parse_registry_entries(
649 registry_json: &str,
650) -> Result<Vec<RegistryEntry>, SnapshotCommandError> {
651 let data = serde_json::from_str::<Value>(registry_json)?;
652 let entries = data
653 .get("Ok")
654 .and_then(Value::as_array)
655 .or_else(|| data.as_array())
656 .ok_or(SnapshotCommandError::Usage(
657 "registry JSON must be an array or {\"Ok\": [...]}",
658 ))?;
659
660 Ok(entries.iter().filter_map(parse_registry_entry).collect())
661}
662
663fn parse_registry_entry(value: &Value) -> Option<RegistryEntry> {
665 let pid = value.get("pid").and_then(Value::as_str)?.to_string();
666 let role = value
667 .get("role")
668 .and_then(Value::as_str)
669 .map(str::to_string);
670 let parent_pid = value
671 .get("record")
672 .and_then(|record| record.get("parent_pid"))
673 .and_then(parse_optional_principal);
674
675 Some(RegistryEntry {
676 pid,
677 role,
678 parent_pid,
679 })
680}
681
682fn parse_optional_principal(value: &Value) -> Option<String> {
684 if value.is_null() {
685 return None;
686 }
687 if let Some(text) = value.as_str() {
688 return Some(text.to_string());
689 }
690 value
691 .as_array()
692 .and_then(|items| items.first())
693 .and_then(Value::as_str)
694 .map(str::to_string)
695}
696
697pub fn targets_from_registry(
699 registry: &[RegistryEntry],
700 canister_id: &str,
701 recursive: bool,
702) -> Result<Vec<SnapshotTarget>, SnapshotCommandError> {
703 let by_pid = registry
704 .iter()
705 .map(|entry| (entry.pid.as_str(), entry))
706 .collect::<BTreeMap<_, _>>();
707
708 let root = by_pid
709 .get(canister_id)
710 .ok_or_else(|| SnapshotCommandError::CanisterNotInRegistry(canister_id.to_string()))?;
711
712 let mut targets = Vec::new();
713 let mut seen = BTreeSet::new();
714 targets.push(SnapshotTarget {
715 canister_id: root.pid.clone(),
716 role: root.role.clone(),
717 parent_canister_id: root.parent_pid.clone(),
718 });
719 seen.insert(root.pid.clone());
720
721 let mut queue = VecDeque::from([root.pid.clone()]);
722 while let Some(parent) = queue.pop_front() {
723 for child in registry
724 .iter()
725 .filter(|entry| entry.parent_pid.as_deref() == Some(parent.as_str()))
726 {
727 if seen.insert(child.pid.clone()) {
728 targets.push(SnapshotTarget {
729 canister_id: child.pid.clone(),
730 role: child.role.clone(),
731 parent_canister_id: child.parent_pid.clone(),
732 });
733 if recursive {
734 queue.push_back(child.pid.clone());
735 }
736 }
737 }
738 }
739
740 Ok(targets)
741}
742
743fn build_manifest(
745 options: &SnapshotDownloadOptions,
746 targets: &[SnapshotTarget],
747 artifacts: &[SnapshotArtifact],
748) -> Result<FleetBackupManifest, SnapshotCommandError> {
749 let topology_records = targets
750 .iter()
751 .enumerate()
752 .map(|(index, target)| topology_record(options, index, target))
753 .collect::<Result<Vec<_>, _>>()?;
754 let topology_hash = TopologyHasher::hash(&topology_records);
755 let roles = targets
756 .iter()
757 .enumerate()
758 .map(|(index, target)| target_role(options, index, target))
759 .collect::<BTreeSet<_>>()
760 .into_iter()
761 .collect::<Vec<_>>();
762
763 let manifest = FleetBackupManifest {
764 manifest_version: 1,
765 backup_id: backup_id(options),
766 created_at: timestamp_placeholder(),
767 tool: ToolMetadata {
768 name: "canic-cli".to_string(),
769 version: env!("CARGO_PKG_VERSION").to_string(),
770 },
771 source: SourceMetadata {
772 environment: options
773 .network
774 .clone()
775 .unwrap_or_else(|| "local".to_string()),
776 root_canister: options
777 .root
778 .clone()
779 .unwrap_or_else(|| options.canister.clone()),
780 },
781 consistency: ConsistencySection {
782 mode: ConsistencyMode::CrashConsistent,
783 backup_units: vec![BackupUnit {
784 unit_id: "snapshot-selection".to_string(),
785 kind: if options.include_children {
786 BackupUnitKind::SubtreeRooted
787 } else {
788 BackupUnitKind::Flat
789 },
790 roles,
791 consistency_reason: if options.include_children {
792 None
793 } else {
794 Some("explicit single-canister snapshot selection".to_string())
795 },
796 dependency_closure: Vec::new(),
797 topology_validation: if options.include_children {
798 "registry-subtree-selection".to_string()
799 } else {
800 "explicit-selection".to_string()
801 },
802 quiescence_strategy: None,
803 }],
804 },
805 fleet: FleetSection {
806 topology_hash_algorithm: topology_hash.algorithm,
807 topology_hash_input: topology_hash.input,
808 discovery_topology_hash: topology_hash.hash.clone(),
809 pre_snapshot_topology_hash: topology_hash.hash.clone(),
810 topology_hash: topology_hash.hash,
811 members: targets
812 .iter()
813 .enumerate()
814 .map(|(index, target)| fleet_member(options, index, target, artifacts))
815 .collect::<Result<Vec<_>, _>>()?,
816 },
817 verification: VerificationPlan::default(),
818 };
819
820 manifest.validate()?;
821 Ok(manifest)
822}
823
824fn topology_record(
826 options: &SnapshotDownloadOptions,
827 index: usize,
828 target: &SnapshotTarget,
829) -> Result<TopologyRecord, SnapshotCommandError> {
830 Ok(TopologyRecord {
831 pid: parse_principal("fleet.members[].canister_id", &target.canister_id)?,
832 parent_pid: target
833 .parent_canister_id
834 .as_deref()
835 .map(|parent| parse_principal("fleet.members[].parent_canister_id", parent))
836 .transpose()?,
837 role: target_role(options, index, target),
838 module_hash: None,
839 })
840}
841
842fn fleet_member(
844 options: &SnapshotDownloadOptions,
845 index: usize,
846 target: &SnapshotTarget,
847 artifacts: &[SnapshotArtifact],
848) -> Result<FleetMember, SnapshotCommandError> {
849 let Some(artifact) = artifacts
850 .iter()
851 .find(|artifact| artifact.canister_id == target.canister_id)
852 else {
853 return Err(SnapshotCommandError::SnapshotIdUnavailable(format!(
854 "missing artifact for {}",
855 target.canister_id
856 )));
857 };
858 let role = target_role(options, index, target);
859
860 Ok(FleetMember {
861 role: role.clone(),
862 canister_id: target.canister_id.clone(),
863 parent_canister_id: target.parent_canister_id.clone(),
864 subnet_canister_id: options.root.clone(),
865 controller_hint: None,
866 identity_mode: if target.canister_id == options.canister {
867 IdentityMode::Fixed
868 } else {
869 IdentityMode::Relocatable
870 },
871 restore_group: if target.canister_id == options.canister {
872 1
873 } else {
874 2
875 },
876 verification_class: "basic".to_string(),
877 verification_checks: vec![VerificationCheck {
878 kind: "status".to_string(),
879 method: None,
880 roles: vec![role],
881 }],
882 source_snapshot: SourceSnapshot {
883 snapshot_id: artifact.snapshot_id.clone(),
884 module_hash: None,
885 wasm_hash: None,
886 code_version: None,
887 artifact_path: safe_path_segment(&target.canister_id),
888 checksum_algorithm: "sha256".to_string(),
889 checksum: Some(artifact.checksum.clone()),
890 },
891 })
892}
893
894fn target_role(options: &SnapshotDownloadOptions, index: usize, target: &SnapshotTarget) -> String {
896 target.role.clone().unwrap_or_else(|| {
897 if target.canister_id == options.canister {
898 "root".to_string()
899 } else {
900 format!("member-{index}")
901 }
902 })
903}
904
905fn parse_principal(field: &'static str, value: &str) -> Result<Principal, SnapshotCommandError> {
907 Principal::from_text(value).map_err(|_| SnapshotCommandError::InvalidPrincipal {
908 field,
909 value: value.to_string(),
910 })
911}
912
913fn parse_snapshot_id(output: &str) -> Option<String> {
915 output
916 .split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
917 .filter(|part| !part.is_empty())
918 .rev()
919 .find(|part| {
920 part.chars()
921 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
922 })
923 .map(str::to_string)
924}
925
926fn parse_snapshot_list_ids(output: &str) -> Vec<String> {
928 output
929 .lines()
930 .filter_map(|line| {
931 line.split_once(':')
932 .map(|(snapshot_id, _)| snapshot_id.trim())
933 })
934 .filter(|snapshot_id| !snapshot_id.is_empty())
935 .map(str::to_string)
936 .collect()
937}
938
939fn safe_path_segment(value: &str) -> String {
941 value
942 .chars()
943 .map(|c| {
944 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_') {
945 c
946 } else {
947 '_'
948 }
949 })
950 .collect()
951}
952
953fn backup_id(options: &SnapshotDownloadOptions) -> String {
955 options
956 .out
957 .file_name()
958 .and_then(|name| name.to_str())
959 .map_or_else(|| "snapshot-download".to_string(), str::to_string)
960}
961
962fn timestamp_placeholder() -> String {
964 "unknown".to_string()
965}
966
967fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, SnapshotCommandError>
969where
970 I: Iterator<Item = OsString>,
971{
972 args.next()
973 .and_then(|value| value.into_string().ok())
974 .ok_or(SnapshotCommandError::MissingValue(option))
975}
976
977const fn usage() -> &'static str {
979 "usage: canic snapshot download --canister <id> --out <dir> [--root <id> | --registry-json <file>] [--include-children] [--recursive] [--dry-run] [--stop-before-snapshot] [--resume-after-snapshot] [--network <name>]"
980}
981
982#[cfg(test)]
983mod tests {
984 use super::*;
985 use canic_backup::persistence::BackupLayout;
986 use serde_json::json;
987 use std::time::{SystemTime, UNIX_EPOCH};
988
989 const ROOT: &str = "aaaaa-aa";
990 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
991 const GRANDCHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
992
993 #[test]
995 fn parses_wrapped_registry_json() {
996 let json = registry_json();
997
998 let entries = parse_registry_entries(&json).expect("parse registry");
999
1000 assert_eq!(entries.len(), 3);
1001 assert_eq!(entries[1].parent_pid.as_deref(), Some(ROOT));
1002 }
1003
1004 #[test]
1006 fn targets_include_direct_children() {
1007 let entries = parse_registry_entries(®istry_json()).expect("parse registry");
1008
1009 let targets = targets_from_registry(&entries, ROOT, false).expect("resolve targets");
1010
1011 assert_eq!(
1012 targets
1013 .iter()
1014 .map(|target| target.canister_id.as_str())
1015 .collect::<Vec<_>>(),
1016 vec![ROOT, CHILD]
1017 );
1018 }
1019
1020 #[test]
1022 fn targets_include_recursive_children() {
1023 let entries = parse_registry_entries(®istry_json()).expect("parse registry");
1024
1025 let targets = targets_from_registry(&entries, ROOT, true).expect("resolve targets");
1026
1027 assert_eq!(
1028 targets
1029 .iter()
1030 .map(|target| target.canister_id.as_str())
1031 .collect::<Vec<_>>(),
1032 vec![ROOT, CHILD, GRANDCHILD]
1033 );
1034 }
1035
1036 #[test]
1038 fn parses_snapshot_id_from_output() {
1039 let snapshot_id = parse_snapshot_id("Created snapshot: snap_abc-123\n");
1040
1041 assert_eq!(snapshot_id.as_deref(), Some("snap_abc-123"));
1042 }
1043
1044 #[test]
1046 fn parses_snapshot_ids_from_list_output() {
1047 let snapshot_ids = parse_snapshot_list_ids(
1048 "0000000000000000ffffffffff9000050101: 213.76 MiB, taken at 2026-05-03 12:20:53 UTC\n",
1049 );
1050
1051 assert_eq!(snapshot_ids, vec!["0000000000000000ffffffffff9000050101"]);
1052 }
1053
1054 #[test]
1056 fn parses_download_options() {
1057 let options = SnapshotDownloadOptions::parse([
1058 OsString::from("--canister"),
1059 OsString::from(ROOT),
1060 OsString::from("--out"),
1061 OsString::from("backups/test"),
1062 OsString::from("--registry-json"),
1063 OsString::from("registry.json"),
1064 OsString::from("--recursive"),
1065 OsString::from("--dry-run"),
1066 OsString::from("--stop-before-snapshot"),
1067 OsString::from("--resume-after-snapshot"),
1068 ])
1069 .expect("parse options");
1070
1071 assert_eq!(options.canister, ROOT);
1072 assert!(options.include_children);
1073 assert!(options.recursive);
1074 assert!(options.dry_run);
1075 assert_eq!(options.lifecycle, SnapshotLifecycleMode::StopAndResume);
1076 }
1077
1078 #[cfg(unix)]
1080 #[test]
1081 fn download_snapshots_writes_manifest_and_durable_journal() {
1082 use std::os::unix::fs::PermissionsExt;
1083
1084 let root = temp_dir("canic-cli-download");
1085 let fake_dfx = root.join("fake-dfx.sh");
1086 fs::create_dir_all(&root).expect("create temp root");
1087 fs::write(
1088 &fake_dfx,
1089 r#"#!/bin/sh
1090set -eu
1091if [ "$1" = "canister" ] && [ "$2" = "snapshot" ] && [ "$3" = "create" ]; then
1092 echo "snapshot-$4"
1093 exit 0
1094fi
1095if [ "$1" = "canister" ] && [ "$2" = "snapshot" ] && [ "$3" = "list" ]; then
1096 exit 0
1097fi
1098if [ "$1" = "canister" ] && [ "$2" = "snapshot" ] && [ "$3" = "download" ]; then
1099 mkdir -p "$7"
1100 printf "%s:%s\n" "$4" "$5" > "$7/snapshot.txt"
1101 exit 0
1102fi
1103echo "unexpected args: $*" >&2
1104exit 1
1105"#,
1106 )
1107 .expect("write fake dfx");
1108 let mut permissions = fs::metadata(&fake_dfx)
1109 .expect("stat fake dfx")
1110 .permissions();
1111 permissions.set_mode(0o755);
1112 fs::set_permissions(&fake_dfx, permissions).expect("chmod fake dfx");
1113
1114 let out = root.join("backup");
1115 let options = SnapshotDownloadOptions {
1116 canister: ROOT.to_string(),
1117 out: out.clone(),
1118 root: None,
1119 registry_json: None,
1120 include_children: false,
1121 recursive: false,
1122 dry_run: false,
1123 lifecycle: SnapshotLifecycleMode::SnapshotOnly,
1124 network: None,
1125 dfx: fake_dfx.display().to_string(),
1126 };
1127
1128 let result = download_snapshots(&options).expect("download snapshots");
1129 let layout = BackupLayout::new(out);
1130 let journal = layout.read_journal().expect("read journal");
1131 let manifest = layout.read_manifest().expect("read manifest");
1132
1133 fs::remove_dir_all(root).expect("remove temp root");
1134 assert_eq!(result.artifacts.len(), 1);
1135 assert_eq!(journal.artifacts.len(), 1);
1136 assert_eq!(journal.artifacts[0].state, ArtifactState::Durable);
1137 assert!(journal.artifacts[0].checksum.is_some());
1138 assert_eq!(manifest.backup_id, journal.backup_id);
1139 assert_eq!(manifest.fleet.members.len(), 1);
1140 assert_eq!(manifest.fleet.members[0].canister_id, ROOT);
1141 assert_eq!(
1142 manifest.fleet.members[0].source_snapshot.snapshot_id,
1143 "snapshot-aaaaa-aa"
1144 );
1145 assert_eq!(
1146 manifest.fleet.members[0]
1147 .source_snapshot
1148 .checksum
1149 .as_deref(),
1150 journal.artifacts[0].checksum.as_deref()
1151 );
1152 }
1153
1154 fn registry_json() -> String {
1156 json!({
1157 "Ok": [
1158 {
1159 "pid": ROOT,
1160 "role": "root",
1161 "record": {
1162 "pid": ROOT,
1163 "role": "root",
1164 "parent_pid": null
1165 }
1166 },
1167 {
1168 "pid": CHILD,
1169 "role": "app",
1170 "record": {
1171 "pid": CHILD,
1172 "role": "app",
1173 "parent_pid": ROOT
1174 }
1175 },
1176 {
1177 "pid": GRANDCHILD,
1178 "role": "worker",
1179 "record": {
1180 "pid": GRANDCHILD,
1181 "role": "worker",
1182 "parent_pid": [CHILD]
1183 }
1184 }
1185 ]
1186 })
1187 .to_string()
1188 }
1189
1190 fn temp_dir(prefix: &str) -> PathBuf {
1192 let nanos = SystemTime::now()
1193 .duration_since(UNIX_EPOCH)
1194 .expect("system time after epoch")
1195 .as_nanos();
1196 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
1197 }
1198}