Skip to main content

canic_cli/snapshot/
mod.rs

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///
25/// SnapshotCommandError
26///
27
28#[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///
77/// SnapshotDownloadOptions
78///
79
80#[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    /// Parse snapshot download options from CLI arguments.
96    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///
162/// SnapshotLifecycleMode
163///
164
165#[derive(Clone, Copy, Debug, Eq, PartialEq)]
166pub enum SnapshotLifecycleMode {
167    SnapshotOnly,
168    StopBeforeSnapshot,
169    ResumeAfterSnapshot,
170    StopAndResume,
171}
172
173impl SnapshotLifecycleMode {
174    /// Build the lifecycle mode from CLI stop/resume flags.
175    #[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    /// Return whether the CLI should stop before snapshot creation.
186    #[must_use]
187    pub const fn stop_before_snapshot(self) -> bool {
188        matches!(self, Self::StopBeforeSnapshot | Self::StopAndResume)
189    }
190
191    /// Return whether the CLI should start after snapshot capture.
192    #[must_use]
193    pub const fn resume_after_snapshot(self) -> bool {
194        matches!(self, Self::ResumeAfterSnapshot | Self::StopAndResume)
195    }
196}
197
198///
199/// SnapshotTarget
200///
201
202#[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
209/// Run a snapshot subcommand.
210pub 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///
239/// SnapshotDownloadResult
240///
241
242#[derive(Clone, Debug, Eq, PartialEq)]
243pub struct SnapshotDownloadResult {
244    pub artifacts: Vec<SnapshotArtifact>,
245}
246
247///
248/// SnapshotArtifact
249///
250
251#[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
259/// Create and download snapshots for the selected canister set.
260pub 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
372// Replace one artifact row in the mutable journal.
373fn 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
381/// Resolve the selected canister plus optional direct/recursive children.
382pub 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(&registry, &options.canister, options.recursive)
395}
396
397// Load registry entries from a file or live root query.
398fn 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(&registry_json)
412}
413
414// Run `dfx canister call <root> canic_subnet_registry --output json`.
415fn 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
426// Create one canister snapshot and parse the snapshot id from dfx output.
427fn 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
453// List the existing snapshot ids for one canister.
454fn 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
466// Stop a canister before taking a snapshot when explicitly requested.
467fn 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
478// Start a canister after snapshot capture when explicitly requested.
479fn 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
490// Run one snapshot operation with optional stop/start lifecycle commands.
491fn 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
518// Download one canister snapshot into the target artifact directory.
519fn 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
533// Add optional `dfx canister` network arguments.
534fn add_canister_network_args(command: &mut Command, options: &SnapshotDownloadOptions) {
535    if let Some(network) = &options.network {
536        command.args(["--network", network]);
537    }
538}
539
540// Execute a command and capture stdout.
541fn 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
554// Execute a command and capture stdout plus stderr on success.
555fn 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
570// Execute a command and require a successful status.
571fn 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
584// Render a command for diagnostics.
585fn 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
595// Render one dry-run create command.
596fn 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
604// Render one dry-run download command.
605fn 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
618// Render one dry-run stop command.
619fn 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
627// Render one dry-run start command.
628fn 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///
637/// RegistryEntry
638///
639
640#[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
647/// Parse the `dfx --output json` subnet registry shape.
648pub 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
663// Parse one registry entry from dfx JSON.
664fn 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
682// Parse optional principal JSON emitted as null, string, or optional vector form.
683fn 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
697/// Resolve selected target and children from registry entries.
698pub 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
743// Build a validated manifest for one successful snapshot download run.
744fn 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
824// Build one canonical topology record for manifest hashing.
825fn 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
842// Build one manifest member from a captured durable artifact.
843fn 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
894// Return the manifest role for one selected snapshot target.
895fn 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
905// Parse one principal used by generated topology manifest metadata.
906fn 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
913// Parse a likely snapshot id from dfx output.
914fn 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
926// Parse dfx snapshot list output into snapshot ids.
927fn 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
939// Convert a principal into a conservative filesystem path segment.
940fn 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
953// Build a stable backup id for this command's output directory.
954fn 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
962// Return a placeholder timestamp until the CLI owns a clock abstraction.
963fn timestamp_placeholder() -> String {
964    "unknown".to_string()
965}
966
967// Read the next required option value.
968fn 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
977// Return snapshot command usage text.
978const 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    // Ensure dfx registry JSON parses in the wrapped Ok shape.
994    #[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    // Ensure direct-child resolution includes only one level.
1005    #[test]
1006    fn targets_include_direct_children() {
1007        let entries = parse_registry_entries(&registry_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    // Ensure recursive resolution walks descendants.
1021    #[test]
1022    fn targets_include_recursive_children() {
1023        let entries = parse_registry_entries(&registry_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    // Ensure snapshot ids can be extracted from common command output.
1037    #[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    // Ensure dfx snapshot list output can be used when create is quiet.
1045    #[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    // Ensure option parsing covers the intended dry-run command.
1055    #[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    // Ensure the actual command path writes a manifest and durable journal.
1079    #[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    // Build representative subnet registry JSON.
1155    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    // Build a unique temporary directory.
1191    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}