1use canic_backup::{
2 artifacts::{ArtifactChecksum, ArtifactChecksumError},
3 journal::JournalValidationError,
4 journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
5 persistence::{BackupLayout, PersistenceError},
6};
7use serde_json::Value;
8use std::{
9 collections::{BTreeMap, BTreeSet, VecDeque},
10 ffi::OsString,
11 fs,
12 path::{Path, PathBuf},
13 process::Command,
14};
15use thiserror::Error as ThisError;
16
17#[derive(Debug, ThisError)]
22pub enum SnapshotCommandError {
23 #[error("{0}")]
24 Usage(&'static str),
25
26 #[error("missing required option {0}")]
27 MissingOption(&'static str),
28
29 #[error("unknown option {0}")]
30 UnknownOption(String),
31
32 #[error("option {0} requires a value")]
33 MissingValue(&'static str),
34
35 #[error("cannot combine --root and --registry-json")]
36 ConflictingRegistrySources,
37
38 #[error("registry JSON did not contain the requested canister {0}")]
39 CanisterNotInRegistry(String),
40
41 #[error("dfx command failed: {command}\n{stderr}")]
42 DfxFailed { command: String, stderr: String },
43
44 #[error("could not parse snapshot id from dfx output: {0}")]
45 SnapshotIdUnavailable(String),
46
47 #[error(transparent)]
48 Io(#[from] std::io::Error),
49
50 #[error(transparent)]
51 Json(#[from] serde_json::Error),
52
53 #[error(transparent)]
54 Checksum(#[from] ArtifactChecksumError),
55
56 #[error(transparent)]
57 Persistence(#[from] PersistenceError),
58
59 #[error(transparent)]
60 Journal(#[from] JournalValidationError),
61}
62
63#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct SnapshotDownloadOptions {
69 pub canister: String,
70 pub out: PathBuf,
71 pub root: Option<String>,
72 pub registry_json: Option<PathBuf>,
73 pub include_children: bool,
74 pub recursive: bool,
75 pub dry_run: bool,
76 pub lifecycle: SnapshotLifecycleMode,
77 pub network: Option<String>,
78 pub dfx: String,
79}
80
81impl SnapshotDownloadOptions {
82 pub fn parse<I>(args: I) -> Result<Self, SnapshotCommandError>
84 where
85 I: IntoIterator<Item = OsString>,
86 {
87 let mut canister = None;
88 let mut out = None;
89 let mut root = None;
90 let mut registry_json = None;
91 let mut include_children = false;
92 let mut recursive = false;
93 let mut dry_run = false;
94 let mut stop_before_snapshot = false;
95 let mut resume_after_snapshot = false;
96 let mut network = None;
97 let mut dfx = "dfx".to_string();
98
99 let mut args = args.into_iter();
100 while let Some(arg) = args.next() {
101 let arg = arg
102 .into_string()
103 .map_err(|_| SnapshotCommandError::Usage(usage()))?;
104 match arg.as_str() {
105 "--canister" => canister = Some(next_value(&mut args, "--canister")?),
106 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
107 "--root" => root = Some(next_value(&mut args, "--root")?),
108 "--registry-json" => {
109 registry_json = Some(PathBuf::from(next_value(&mut args, "--registry-json")?));
110 }
111 "--include-children" => include_children = true,
112 "--recursive" => {
113 recursive = true;
114 include_children = true;
115 }
116 "--dry-run" => dry_run = true,
117 "--stop-before-snapshot" => stop_before_snapshot = true,
118 "--resume-after-snapshot" => resume_after_snapshot = true,
119 "--network" => network = Some(next_value(&mut args, "--network")?),
120 "--dfx" => dfx = next_value(&mut args, "--dfx")?,
121 "--help" | "-h" => return Err(SnapshotCommandError::Usage(usage())),
122 _ => return Err(SnapshotCommandError::UnknownOption(arg)),
123 }
124 }
125
126 if root.is_some() && registry_json.is_some() {
127 return Err(SnapshotCommandError::ConflictingRegistrySources);
128 }
129
130 Ok(Self {
131 canister: canister.ok_or(SnapshotCommandError::MissingOption("--canister"))?,
132 out: out.ok_or(SnapshotCommandError::MissingOption("--out"))?,
133 root,
134 registry_json,
135 include_children,
136 recursive,
137 dry_run,
138 lifecycle: SnapshotLifecycleMode::from_flags(
139 stop_before_snapshot,
140 resume_after_snapshot,
141 ),
142 network,
143 dfx,
144 })
145 }
146}
147
148#[derive(Clone, Copy, Debug, Eq, PartialEq)]
153pub enum SnapshotLifecycleMode {
154 SnapshotOnly,
155 StopBeforeSnapshot,
156 ResumeAfterSnapshot,
157 StopAndResume,
158}
159
160impl SnapshotLifecycleMode {
161 #[must_use]
163 pub const fn from_flags(stop_before_snapshot: bool, resume_after_snapshot: bool) -> Self {
164 match (stop_before_snapshot, resume_after_snapshot) {
165 (false, false) => Self::SnapshotOnly,
166 (true, false) => Self::StopBeforeSnapshot,
167 (false, true) => Self::ResumeAfterSnapshot,
168 (true, true) => Self::StopAndResume,
169 }
170 }
171
172 #[must_use]
174 pub const fn stop_before_snapshot(self) -> bool {
175 matches!(self, Self::StopBeforeSnapshot | Self::StopAndResume)
176 }
177
178 #[must_use]
180 pub const fn resume_after_snapshot(self) -> bool {
181 matches!(self, Self::ResumeAfterSnapshot | Self::StopAndResume)
182 }
183}
184
185#[derive(Clone, Debug, Eq, PartialEq)]
190pub struct SnapshotTarget {
191 pub canister_id: String,
192 pub role: Option<String>,
193}
194
195pub fn run<I>(args: I) -> Result<(), SnapshotCommandError>
197where
198 I: IntoIterator<Item = OsString>,
199{
200 let mut args = args.into_iter();
201 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
202 return Err(SnapshotCommandError::Usage(usage()));
203 };
204
205 match command.as_str() {
206 "download" => {
207 let options = SnapshotDownloadOptions::parse(args)?;
208 let result = download_snapshots(&options)?;
209 for artifact in result.artifacts {
210 println!(
211 "{} {} {}",
212 artifact.canister_id,
213 artifact.snapshot_id,
214 artifact.path.display()
215 );
216 }
217 Ok(())
218 }
219 "help" | "--help" | "-h" => Err(SnapshotCommandError::Usage(usage())),
220 _ => Err(SnapshotCommandError::UnknownOption(command)),
221 }
222}
223
224#[derive(Clone, Debug, Eq, PartialEq)]
229pub struct SnapshotDownloadResult {
230 pub artifacts: Vec<SnapshotArtifact>,
231}
232
233#[derive(Clone, Debug, Eq, PartialEq)]
238pub struct SnapshotArtifact {
239 pub canister_id: String,
240 pub snapshot_id: String,
241 pub path: PathBuf,
242 pub checksum: String,
243}
244
245pub fn download_snapshots(
247 options: &SnapshotDownloadOptions,
248) -> Result<SnapshotDownloadResult, SnapshotCommandError> {
249 let targets = resolve_targets(options)?;
250 let mut artifacts = Vec::with_capacity(targets.len());
251 let mut journal = DownloadJournal {
252 journal_version: 1,
253 backup_id: backup_id(options),
254 artifacts: Vec::new(),
255 };
256 let layout = BackupLayout::new(options.out.clone());
257
258 for target in targets {
259 let artifact_path = options.out.join(safe_path_segment(&target.canister_id));
260 let temp_path = options
261 .out
262 .join(format!("{}.tmp", safe_path_segment(&target.canister_id)));
263
264 if options.dry_run {
265 if options.lifecycle.stop_before_snapshot() {
266 println!(
267 "{}",
268 stop_canister_command_display(options, &target.canister_id)
269 );
270 }
271 println!(
272 "{}",
273 create_snapshot_command_display(options, &target.canister_id)
274 );
275 println!(
276 "{}",
277 download_snapshot_command_display(options, &target.canister_id, "<snapshot-id>")
278 );
279 artifacts.push(SnapshotArtifact {
280 canister_id: target.canister_id.clone(),
281 snapshot_id: "<snapshot-id>".to_string(),
282 path: artifact_path,
283 checksum: "<sha256>".to_string(),
284 });
285 if options.lifecycle.resume_after_snapshot() {
286 println!(
287 "{}",
288 start_canister_command_display(options, &target.canister_id)
289 );
290 }
291 continue;
292 }
293
294 let artifact = with_optional_stop(options, &target.canister_id, || {
295 let snapshot_id = create_snapshot(options, &target.canister_id)?;
296 let mut entry = ArtifactJournalEntry {
297 canister_id: target.canister_id.clone(),
298 snapshot_id: snapshot_id.clone(),
299 state: ArtifactState::Created,
300 temp_path: None,
301 artifact_path: artifact_path.display().to_string(),
302 checksum_algorithm: "sha256".to_string(),
303 checksum: None,
304 updated_at: timestamp_placeholder(),
305 };
306 journal.artifacts.push(entry.clone());
307 layout.write_journal(&journal)?;
308
309 if temp_path.exists() {
310 fs::remove_dir_all(&temp_path)?;
311 }
312 fs::create_dir_all(&temp_path)?;
313 download_snapshot(options, &target.canister_id, &snapshot_id, &temp_path)?;
314 entry.advance_to(ArtifactState::Downloaded, timestamp_placeholder())?;
315 entry.temp_path = Some(temp_path.display().to_string());
316 update_journal_entry(&mut journal, &entry);
317 layout.write_journal(&journal)?;
318
319 let checksum = ArtifactChecksum::from_path(&temp_path)?;
320 entry.checksum = Some(checksum.hash.clone());
321 entry.advance_to(ArtifactState::ChecksumVerified, timestamp_placeholder())?;
322 update_journal_entry(&mut journal, &entry);
323 layout.write_journal(&journal)?;
324
325 if artifact_path.exists() {
326 return Err(std::io::Error::new(
327 std::io::ErrorKind::AlreadyExists,
328 format!("artifact path already exists: {}", artifact_path.display()),
329 )
330 .into());
331 }
332 fs::rename(&temp_path, &artifact_path)?;
333 entry.temp_path = None;
334 entry.advance_to(ArtifactState::Durable, timestamp_placeholder())?;
335 update_journal_entry(&mut journal, &entry);
336 layout.write_journal(&journal)?;
337
338 Ok(SnapshotArtifact {
339 canister_id: target.canister_id.clone(),
340 snapshot_id,
341 path: artifact_path,
342 checksum: checksum.hash,
343 })
344 })?;
345
346 artifacts.push(artifact);
347 }
348
349 Ok(SnapshotDownloadResult { artifacts })
350}
351
352fn update_journal_entry(journal: &mut DownloadJournal, entry: &ArtifactJournalEntry) {
354 if let Some(existing) = journal.artifacts.iter_mut().find(|existing| {
355 existing.canister_id == entry.canister_id && existing.snapshot_id == entry.snapshot_id
356 }) {
357 *existing = entry.clone();
358 }
359}
360
361pub fn resolve_targets(
363 options: &SnapshotDownloadOptions,
364) -> Result<Vec<SnapshotTarget>, SnapshotCommandError> {
365 if !options.include_children {
366 return Ok(vec![SnapshotTarget {
367 canister_id: options.canister.clone(),
368 role: None,
369 }]);
370 }
371
372 let registry = load_registry_entries(options)?;
373 targets_from_registry(®istry, &options.canister, options.recursive)
374}
375
376fn load_registry_entries(
378 options: &SnapshotDownloadOptions,
379) -> Result<Vec<RegistryEntry>, SnapshotCommandError> {
380 let registry_json = if let Some(path) = &options.registry_json {
381 fs::read_to_string(path)?
382 } else if let Some(root) = &options.root {
383 call_subnet_registry(options, root)?
384 } else {
385 return Err(SnapshotCommandError::MissingOption(
386 "--root or --registry-json when using --include-children",
387 ));
388 };
389
390 parse_registry_entries(®istry_json)
391}
392
393fn call_subnet_registry(
395 options: &SnapshotDownloadOptions,
396 root: &str,
397) -> Result<String, SnapshotCommandError> {
398 let mut command = Command::new(&options.dfx);
399 command.arg("canister");
400 add_canister_network_args(&mut command, options);
401 command.args(["call", root, "canic_subnet_registry", "--output", "json"]);
402 run_output(&mut command)
403}
404
405fn create_snapshot(
407 options: &SnapshotDownloadOptions,
408 canister_id: &str,
409) -> Result<String, SnapshotCommandError> {
410 let before = list_snapshot_ids(options, canister_id)?;
411 let mut command = Command::new(&options.dfx);
412 command.arg("canister");
413 add_canister_network_args(&mut command, options);
414 command.args(["snapshot", "create", canister_id]);
415 let output = run_output_with_stderr(&mut command)?;
416 if let Some(snapshot_id) = parse_snapshot_id(&output) {
417 return Ok(snapshot_id);
418 }
419
420 let before = before.into_iter().collect::<BTreeSet<_>>();
421 let mut new_ids = list_snapshot_ids(options, canister_id)?
422 .into_iter()
423 .filter(|snapshot_id| !before.contains(snapshot_id))
424 .collect::<Vec<_>>();
425 if new_ids.len() == 1 {
426 Ok(new_ids.remove(0))
427 } else {
428 Err(SnapshotCommandError::SnapshotIdUnavailable(output))
429 }
430}
431
432fn list_snapshot_ids(
434 options: &SnapshotDownloadOptions,
435 canister_id: &str,
436) -> Result<Vec<String>, SnapshotCommandError> {
437 let mut command = Command::new(&options.dfx);
438 command.arg("canister");
439 add_canister_network_args(&mut command, options);
440 command.args(["snapshot", "list", canister_id]);
441 let output = run_output(&mut command)?;
442 Ok(parse_snapshot_list_ids(&output))
443}
444
445fn stop_canister(
447 options: &SnapshotDownloadOptions,
448 canister_id: &str,
449) -> Result<(), SnapshotCommandError> {
450 let mut command = Command::new(&options.dfx);
451 command.arg("canister");
452 add_canister_network_args(&mut command, options);
453 command.args(["stop", canister_id]);
454 run_status(&mut command)
455}
456
457fn start_canister(
459 options: &SnapshotDownloadOptions,
460 canister_id: &str,
461) -> Result<(), SnapshotCommandError> {
462 let mut command = Command::new(&options.dfx);
463 command.arg("canister");
464 add_canister_network_args(&mut command, options);
465 command.args(["start", canister_id]);
466 run_status(&mut command)
467}
468
469fn with_optional_stop<T>(
471 options: &SnapshotDownloadOptions,
472 canister_id: &str,
473 operation: impl FnOnce() -> Result<T, SnapshotCommandError>,
474) -> Result<T, SnapshotCommandError> {
475 if options.lifecycle.stop_before_snapshot() {
476 stop_canister(options, canister_id)?;
477 }
478
479 let result = operation();
480
481 if options.lifecycle.resume_after_snapshot() {
482 match result {
483 Ok(value) => {
484 start_canister(options, canister_id)?;
485 Ok(value)
486 }
487 Err(error) => {
488 let _ = start_canister(options, canister_id);
489 Err(error)
490 }
491 }
492 } else {
493 result
494 }
495}
496
497fn download_snapshot(
499 options: &SnapshotDownloadOptions,
500 canister_id: &str,
501 snapshot_id: &str,
502 artifact_path: &Path,
503) -> Result<(), SnapshotCommandError> {
504 let mut command = Command::new(&options.dfx);
505 command.arg("canister");
506 add_canister_network_args(&mut command, options);
507 command.args(["snapshot", "download", canister_id, snapshot_id, "--dir"]);
508 command.arg(artifact_path);
509 run_status(&mut command)
510}
511
512fn add_canister_network_args(command: &mut Command, options: &SnapshotDownloadOptions) {
514 if let Some(network) = &options.network {
515 command.args(["--network", network]);
516 }
517}
518
519fn run_output(command: &mut Command) -> Result<String, SnapshotCommandError> {
521 let display = command_display(command);
522 let output = command.output()?;
523 if output.status.success() {
524 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
525 } else {
526 Err(SnapshotCommandError::DfxFailed {
527 command: display,
528 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
529 })
530 }
531}
532
533fn run_output_with_stderr(command: &mut Command) -> Result<String, SnapshotCommandError> {
535 let display = command_display(command);
536 let output = command.output()?;
537 if output.status.success() {
538 let mut text = String::from_utf8_lossy(&output.stdout).to_string();
539 text.push_str(&String::from_utf8_lossy(&output.stderr));
540 Ok(text.trim().to_string())
541 } else {
542 Err(SnapshotCommandError::DfxFailed {
543 command: display,
544 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
545 })
546 }
547}
548
549fn run_status(command: &mut Command) -> Result<(), SnapshotCommandError> {
551 let display = command_display(command);
552 let output = command.output()?;
553 if output.status.success() {
554 Ok(())
555 } else {
556 Err(SnapshotCommandError::DfxFailed {
557 command: display,
558 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
559 })
560 }
561}
562
563fn command_display(command: &Command) -> String {
565 let mut parts = vec![command.get_program().to_string_lossy().to_string()];
566 parts.extend(
567 command
568 .get_args()
569 .map(|arg| arg.to_string_lossy().to_string()),
570 );
571 parts.join(" ")
572}
573
574fn create_snapshot_command_display(options: &SnapshotDownloadOptions, canister_id: &str) -> String {
576 let mut command = Command::new(&options.dfx);
577 command.arg("canister");
578 add_canister_network_args(&mut command, options);
579 command.args(["snapshot", "create", canister_id]);
580 command_display(&command)
581}
582
583fn download_snapshot_command_display(
585 options: &SnapshotDownloadOptions,
586 canister_id: &str,
587 snapshot_id: &str,
588) -> String {
589 let mut command = Command::new(&options.dfx);
590 command.arg("canister");
591 add_canister_network_args(&mut command, options);
592 command.args(["snapshot", "download", canister_id, snapshot_id, "--dir"]);
593 command.arg(options.out.join(safe_path_segment(canister_id)));
594 command_display(&command)
595}
596
597fn stop_canister_command_display(options: &SnapshotDownloadOptions, canister_id: &str) -> String {
599 let mut command = Command::new(&options.dfx);
600 command.arg("canister");
601 add_canister_network_args(&mut command, options);
602 command.args(["stop", canister_id]);
603 command_display(&command)
604}
605
606fn start_canister_command_display(options: &SnapshotDownloadOptions, canister_id: &str) -> String {
608 let mut command = Command::new(&options.dfx);
609 command.arg("canister");
610 add_canister_network_args(&mut command, options);
611 command.args(["start", canister_id]);
612 command_display(&command)
613}
614
615#[derive(Clone, Debug, Eq, PartialEq)]
620pub struct RegistryEntry {
621 pub pid: String,
622 pub role: Option<String>,
623 pub parent_pid: Option<String>,
624}
625
626pub fn parse_registry_entries(
628 registry_json: &str,
629) -> Result<Vec<RegistryEntry>, SnapshotCommandError> {
630 let data = serde_json::from_str::<Value>(registry_json)?;
631 let entries = data
632 .get("Ok")
633 .and_then(Value::as_array)
634 .or_else(|| data.as_array())
635 .ok_or(SnapshotCommandError::Usage(
636 "registry JSON must be an array or {\"Ok\": [...]}",
637 ))?;
638
639 Ok(entries.iter().filter_map(parse_registry_entry).collect())
640}
641
642fn parse_registry_entry(value: &Value) -> Option<RegistryEntry> {
644 let pid = value.get("pid").and_then(Value::as_str)?.to_string();
645 let role = value
646 .get("role")
647 .and_then(Value::as_str)
648 .map(str::to_string);
649 let parent_pid = value
650 .get("record")
651 .and_then(|record| record.get("parent_pid"))
652 .and_then(parse_optional_principal);
653
654 Some(RegistryEntry {
655 pid,
656 role,
657 parent_pid,
658 })
659}
660
661fn parse_optional_principal(value: &Value) -> Option<String> {
663 if value.is_null() {
664 return None;
665 }
666 if let Some(text) = value.as_str() {
667 return Some(text.to_string());
668 }
669 value
670 .as_array()
671 .and_then(|items| items.first())
672 .and_then(Value::as_str)
673 .map(str::to_string)
674}
675
676pub fn targets_from_registry(
678 registry: &[RegistryEntry],
679 canister_id: &str,
680 recursive: bool,
681) -> Result<Vec<SnapshotTarget>, SnapshotCommandError> {
682 let by_pid = registry
683 .iter()
684 .map(|entry| (entry.pid.as_str(), entry))
685 .collect::<BTreeMap<_, _>>();
686
687 let root = by_pid
688 .get(canister_id)
689 .ok_or_else(|| SnapshotCommandError::CanisterNotInRegistry(canister_id.to_string()))?;
690
691 let mut targets = Vec::new();
692 let mut seen = BTreeSet::new();
693 targets.push(SnapshotTarget {
694 canister_id: root.pid.clone(),
695 role: root.role.clone(),
696 });
697 seen.insert(root.pid.clone());
698
699 let mut queue = VecDeque::from([root.pid.clone()]);
700 while let Some(parent) = queue.pop_front() {
701 for child in registry
702 .iter()
703 .filter(|entry| entry.parent_pid.as_deref() == Some(parent.as_str()))
704 {
705 if seen.insert(child.pid.clone()) {
706 targets.push(SnapshotTarget {
707 canister_id: child.pid.clone(),
708 role: child.role.clone(),
709 });
710 if recursive {
711 queue.push_back(child.pid.clone());
712 }
713 }
714 }
715 }
716
717 Ok(targets)
718}
719
720fn parse_snapshot_id(output: &str) -> Option<String> {
722 output
723 .split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
724 .filter(|part| !part.is_empty())
725 .rev()
726 .find(|part| {
727 part.chars()
728 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
729 })
730 .map(str::to_string)
731}
732
733fn parse_snapshot_list_ids(output: &str) -> Vec<String> {
735 output
736 .lines()
737 .filter_map(|line| {
738 line.split_once(':')
739 .map(|(snapshot_id, _)| snapshot_id.trim())
740 })
741 .filter(|snapshot_id| !snapshot_id.is_empty())
742 .map(str::to_string)
743 .collect()
744}
745
746fn safe_path_segment(value: &str) -> String {
748 value
749 .chars()
750 .map(|c| {
751 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_') {
752 c
753 } else {
754 '_'
755 }
756 })
757 .collect()
758}
759
760fn backup_id(options: &SnapshotDownloadOptions) -> String {
762 options
763 .out
764 .file_name()
765 .and_then(|name| name.to_str())
766 .map_or_else(|| "snapshot-download".to_string(), str::to_string)
767}
768
769fn timestamp_placeholder() -> String {
771 "unknown".to_string()
772}
773
774fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, SnapshotCommandError>
776where
777 I: Iterator<Item = OsString>,
778{
779 args.next()
780 .and_then(|value| value.into_string().ok())
781 .ok_or(SnapshotCommandError::MissingValue(option))
782}
783
784const fn usage() -> &'static str {
786 "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>]"
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792 use canic_backup::persistence::BackupLayout;
793 use serde_json::json;
794 use std::time::{SystemTime, UNIX_EPOCH};
795
796 const ROOT: &str = "aaaaa-aa";
797 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
798 const GRANDCHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
799
800 #[test]
802 fn parses_wrapped_registry_json() {
803 let json = registry_json();
804
805 let entries = parse_registry_entries(&json).expect("parse registry");
806
807 assert_eq!(entries.len(), 3);
808 assert_eq!(entries[1].parent_pid.as_deref(), Some(ROOT));
809 }
810
811 #[test]
813 fn targets_include_direct_children() {
814 let entries = parse_registry_entries(®istry_json()).expect("parse registry");
815
816 let targets = targets_from_registry(&entries, ROOT, false).expect("resolve targets");
817
818 assert_eq!(
819 targets
820 .iter()
821 .map(|target| target.canister_id.as_str())
822 .collect::<Vec<_>>(),
823 vec![ROOT, CHILD]
824 );
825 }
826
827 #[test]
829 fn targets_include_recursive_children() {
830 let entries = parse_registry_entries(®istry_json()).expect("parse registry");
831
832 let targets = targets_from_registry(&entries, ROOT, true).expect("resolve targets");
833
834 assert_eq!(
835 targets
836 .iter()
837 .map(|target| target.canister_id.as_str())
838 .collect::<Vec<_>>(),
839 vec![ROOT, CHILD, GRANDCHILD]
840 );
841 }
842
843 #[test]
845 fn parses_snapshot_id_from_output() {
846 let snapshot_id = parse_snapshot_id("Created snapshot: snap_abc-123\n");
847
848 assert_eq!(snapshot_id.as_deref(), Some("snap_abc-123"));
849 }
850
851 #[test]
853 fn parses_snapshot_ids_from_list_output() {
854 let snapshot_ids = parse_snapshot_list_ids(
855 "0000000000000000ffffffffff9000050101: 213.76 MiB, taken at 2026-05-03 12:20:53 UTC\n",
856 );
857
858 assert_eq!(snapshot_ids, vec!["0000000000000000ffffffffff9000050101"]);
859 }
860
861 #[test]
863 fn parses_download_options() {
864 let options = SnapshotDownloadOptions::parse([
865 OsString::from("--canister"),
866 OsString::from(ROOT),
867 OsString::from("--out"),
868 OsString::from("backups/test"),
869 OsString::from("--registry-json"),
870 OsString::from("registry.json"),
871 OsString::from("--recursive"),
872 OsString::from("--dry-run"),
873 OsString::from("--stop-before-snapshot"),
874 OsString::from("--resume-after-snapshot"),
875 ])
876 .expect("parse options");
877
878 assert_eq!(options.canister, ROOT);
879 assert!(options.include_children);
880 assert!(options.recursive);
881 assert!(options.dry_run);
882 assert_eq!(options.lifecycle, SnapshotLifecycleMode::StopAndResume);
883 }
884
885 #[cfg(unix)]
887 #[test]
888 fn download_snapshots_writes_durable_journal() {
889 use std::os::unix::fs::PermissionsExt;
890
891 let root = temp_dir("canic-cli-download");
892 let fake_dfx = root.join("fake-dfx.sh");
893 fs::create_dir_all(&root).expect("create temp root");
894 fs::write(
895 &fake_dfx,
896 r#"#!/bin/sh
897set -eu
898if [ "$1" = "canister" ] && [ "$2" = "snapshot" ] && [ "$3" = "create" ]; then
899 echo "snapshot-$4"
900 exit 0
901fi
902if [ "$1" = "canister" ] && [ "$2" = "snapshot" ] && [ "$3" = "list" ]; then
903 exit 0
904fi
905if [ "$1" = "canister" ] && [ "$2" = "snapshot" ] && [ "$3" = "download" ]; then
906 mkdir -p "$7"
907 printf "%s:%s\n" "$4" "$5" > "$7/snapshot.txt"
908 exit 0
909fi
910echo "unexpected args: $*" >&2
911exit 1
912"#,
913 )
914 .expect("write fake dfx");
915 let mut permissions = fs::metadata(&fake_dfx)
916 .expect("stat fake dfx")
917 .permissions();
918 permissions.set_mode(0o755);
919 fs::set_permissions(&fake_dfx, permissions).expect("chmod fake dfx");
920
921 let out = root.join("backup");
922 let options = SnapshotDownloadOptions {
923 canister: ROOT.to_string(),
924 out: out.clone(),
925 root: None,
926 registry_json: None,
927 include_children: false,
928 recursive: false,
929 dry_run: false,
930 lifecycle: SnapshotLifecycleMode::SnapshotOnly,
931 network: None,
932 dfx: fake_dfx.display().to_string(),
933 };
934
935 let result = download_snapshots(&options).expect("download snapshots");
936 let journal = BackupLayout::new(out).read_journal().expect("read journal");
937
938 fs::remove_dir_all(root).expect("remove temp root");
939 assert_eq!(result.artifacts.len(), 1);
940 assert_eq!(journal.artifacts.len(), 1);
941 assert_eq!(journal.artifacts[0].state, ArtifactState::Durable);
942 assert!(journal.artifacts[0].checksum.is_some());
943 }
944
945 fn registry_json() -> String {
947 json!({
948 "Ok": [
949 {
950 "pid": ROOT,
951 "role": "root",
952 "record": {
953 "pid": ROOT,
954 "role": "root",
955 "parent_pid": null
956 }
957 },
958 {
959 "pid": CHILD,
960 "role": "app",
961 "record": {
962 "pid": CHILD,
963 "role": "app",
964 "parent_pid": ROOT
965 }
966 },
967 {
968 "pid": GRANDCHILD,
969 "role": "worker",
970 "record": {
971 "pid": GRANDCHILD,
972 "role": "worker",
973 "parent_pid": [CHILD]
974 }
975 }
976 ]
977 })
978 .to_string()
979 }
980
981 fn temp_dir(prefix: &str) -> PathBuf {
983 let nanos = SystemTime::now()
984 .duration_since(UNIX_EPOCH)
985 .expect("system time after epoch")
986 .as_nanos();
987 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
988 }
989}