Skip to main content

canic_cli/snapshot/
mod.rs

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///
18/// SnapshotCommandError
19///
20
21#[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///
64/// SnapshotDownloadOptions
65///
66
67#[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    /// Parse snapshot download options from CLI arguments.
83    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///
149/// SnapshotLifecycleMode
150///
151
152#[derive(Clone, Copy, Debug, Eq, PartialEq)]
153pub enum SnapshotLifecycleMode {
154    SnapshotOnly,
155    StopBeforeSnapshot,
156    ResumeAfterSnapshot,
157    StopAndResume,
158}
159
160impl SnapshotLifecycleMode {
161    /// Build the lifecycle mode from CLI stop/resume flags.
162    #[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    /// Return whether the CLI should stop before snapshot creation.
173    #[must_use]
174    pub const fn stop_before_snapshot(self) -> bool {
175        matches!(self, Self::StopBeforeSnapshot | Self::StopAndResume)
176    }
177
178    /// Return whether the CLI should start after snapshot capture.
179    #[must_use]
180    pub const fn resume_after_snapshot(self) -> bool {
181        matches!(self, Self::ResumeAfterSnapshot | Self::StopAndResume)
182    }
183}
184
185///
186/// SnapshotTarget
187///
188
189#[derive(Clone, Debug, Eq, PartialEq)]
190pub struct SnapshotTarget {
191    pub canister_id: String,
192    pub role: Option<String>,
193}
194
195/// Run a snapshot subcommand.
196pub 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///
225/// SnapshotDownloadResult
226///
227
228#[derive(Clone, Debug, Eq, PartialEq)]
229pub struct SnapshotDownloadResult {
230    pub artifacts: Vec<SnapshotArtifact>,
231}
232
233///
234/// SnapshotArtifact
235///
236
237#[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
245/// Create and download snapshots for the selected canister set.
246pub 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
352// Replace one artifact row in the mutable journal.
353fn 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
361/// Resolve the selected canister plus optional direct/recursive children.
362pub 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(&registry, &options.canister, options.recursive)
374}
375
376// Load registry entries from a file or live root query.
377fn 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(&registry_json)
391}
392
393// Run `dfx canister call <root> canic_subnet_registry --output json`.
394fn 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
405// Create one canister snapshot and parse the snapshot id from dfx output.
406fn 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
432// List the existing snapshot ids for one canister.
433fn 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
445// Stop a canister before taking a snapshot when explicitly requested.
446fn 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
457// Start a canister after snapshot capture when explicitly requested.
458fn 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
469// Run one snapshot operation with optional stop/start lifecycle commands.
470fn 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
497// Download one canister snapshot into the target artifact directory.
498fn 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
512// Add optional `dfx canister` network arguments.
513fn add_canister_network_args(command: &mut Command, options: &SnapshotDownloadOptions) {
514    if let Some(network) = &options.network {
515        command.args(["--network", network]);
516    }
517}
518
519// Execute a command and capture stdout.
520fn 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
533// Execute a command and capture stdout plus stderr on success.
534fn 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
549// Execute a command and require a successful status.
550fn 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
563// Render a command for diagnostics.
564fn 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
574// Render one dry-run create command.
575fn 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
583// Render one dry-run download command.
584fn 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
597// Render one dry-run stop command.
598fn 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
606// Render one dry-run start command.
607fn 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///
616/// RegistryEntry
617///
618
619#[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
626/// Parse the `dfx --output json` subnet registry shape.
627pub 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
642// Parse one registry entry from dfx JSON.
643fn 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
661// Parse optional principal JSON emitted as null, string, or optional vector form.
662fn 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
676/// Resolve selected target and children from registry entries.
677pub 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
720// Parse a likely snapshot id from dfx output.
721fn 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
733// Parse dfx snapshot list output into snapshot ids.
734fn 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
746// Convert a principal into a conservative filesystem path segment.
747fn 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
760// Build a stable backup id for this command's output directory.
761fn 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
769// Return a placeholder timestamp until the CLI owns a clock abstraction.
770fn timestamp_placeholder() -> String {
771    "unknown".to_string()
772}
773
774// Read the next required option value.
775fn 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
784// Return snapshot command usage text.
785const 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    // Ensure dfx registry JSON parses in the wrapped Ok shape.
801    #[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    // Ensure direct-child resolution includes only one level.
812    #[test]
813    fn targets_include_direct_children() {
814        let entries = parse_registry_entries(&registry_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    // Ensure recursive resolution walks descendants.
828    #[test]
829    fn targets_include_recursive_children() {
830        let entries = parse_registry_entries(&registry_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    // Ensure snapshot ids can be extracted from common command output.
844    #[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    // Ensure dfx snapshot list output can be used when create is quiet.
852    #[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    // Ensure option parsing covers the intended dry-run command.
862    #[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    // Ensure the actual command path writes a durable journal using a fake dfx binary.
886    #[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    // Build representative subnet registry JSON.
946    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    // Build a unique temporary directory.
982    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}