Skip to main content

canic_cli/backup/
mod.rs

1use canic_backup::{
2    journal::JournalResumeReport,
3    persistence::{BackupIntegrityReport, BackupLayout, PersistenceError},
4};
5use std::{
6    ffi::OsString,
7    fs,
8    io::{self, Write},
9    path::PathBuf,
10};
11use thiserror::Error as ThisError;
12
13///
14/// BackupCommandError
15///
16
17#[derive(Debug, ThisError)]
18pub enum BackupCommandError {
19    #[error("{0}")]
20    Usage(&'static str),
21
22    #[error("missing required option {0}")]
23    MissingOption(&'static str),
24
25    #[error("unknown option {0}")]
26    UnknownOption(String),
27
28    #[error("option {0} requires a value")]
29    MissingValue(&'static str),
30
31    #[error(transparent)]
32    Io(#[from] std::io::Error),
33
34    #[error(transparent)]
35    Json(#[from] serde_json::Error),
36
37    #[error(transparent)]
38    Persistence(#[from] PersistenceError),
39}
40
41///
42/// BackupVerifyOptions
43///
44
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct BackupVerifyOptions {
47    pub dir: PathBuf,
48    pub out: Option<PathBuf>,
49}
50
51impl BackupVerifyOptions {
52    /// Parse backup verification options from CLI arguments.
53    pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
54    where
55        I: IntoIterator<Item = OsString>,
56    {
57        let mut dir = None;
58        let mut out = None;
59
60        let mut args = args.into_iter();
61        while let Some(arg) = args.next() {
62            let arg = arg
63                .into_string()
64                .map_err(|_| BackupCommandError::Usage(usage()))?;
65            match arg.as_str() {
66                "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
67                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
68                "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
69                _ => return Err(BackupCommandError::UnknownOption(arg)),
70            }
71        }
72
73        Ok(Self {
74            dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
75            out,
76        })
77    }
78}
79
80///
81/// BackupStatusOptions
82///
83
84#[derive(Clone, Debug, Eq, PartialEq)]
85pub struct BackupStatusOptions {
86    pub dir: PathBuf,
87    pub out: Option<PathBuf>,
88}
89
90impl BackupStatusOptions {
91    /// Parse backup status options from CLI arguments.
92    pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
93    where
94        I: IntoIterator<Item = OsString>,
95    {
96        let mut dir = None;
97        let mut out = None;
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(|_| BackupCommandError::Usage(usage()))?;
104            match arg.as_str() {
105                "--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
106                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
107                "--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
108                _ => return Err(BackupCommandError::UnknownOption(arg)),
109            }
110        }
111
112        Ok(Self {
113            dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
114            out,
115        })
116    }
117}
118
119/// Run a backup subcommand.
120pub fn run<I>(args: I) -> Result<(), BackupCommandError>
121where
122    I: IntoIterator<Item = OsString>,
123{
124    let mut args = args.into_iter();
125    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
126        return Err(BackupCommandError::Usage(usage()));
127    };
128
129    match command.as_str() {
130        "status" => {
131            let options = BackupStatusOptions::parse(args)?;
132            let report = backup_status(&options)?;
133            write_status_report(&options, &report)?;
134            Ok(())
135        }
136        "verify" => {
137            let options = BackupVerifyOptions::parse(args)?;
138            let report = verify_backup(&options)?;
139            write_report(&options, &report)?;
140            Ok(())
141        }
142        "help" | "--help" | "-h" => Err(BackupCommandError::Usage(usage())),
143        _ => Err(BackupCommandError::UnknownOption(command)),
144    }
145}
146
147/// Summarize a backup journal's resumable state.
148pub fn backup_status(
149    options: &BackupStatusOptions,
150) -> Result<JournalResumeReport, BackupCommandError> {
151    let layout = BackupLayout::new(options.dir.clone());
152    let journal = layout.read_journal()?;
153    Ok(journal.resume_report())
154}
155
156/// Verify a backup directory's manifest, journal, and durable artifacts.
157pub fn verify_backup(
158    options: &BackupVerifyOptions,
159) -> Result<BackupIntegrityReport, BackupCommandError> {
160    let layout = BackupLayout::new(options.dir.clone());
161    layout.verify_integrity().map_err(BackupCommandError::from)
162}
163
164// Write the journal status report to stdout or a requested output file.
165fn write_status_report(
166    options: &BackupStatusOptions,
167    report: &JournalResumeReport,
168) -> Result<(), BackupCommandError> {
169    if let Some(path) = &options.out {
170        let data = serde_json::to_vec_pretty(report)?;
171        fs::write(path, data)?;
172        return Ok(());
173    }
174
175    let stdout = io::stdout();
176    let mut handle = stdout.lock();
177    serde_json::to_writer_pretty(&mut handle, report)?;
178    writeln!(handle)?;
179    Ok(())
180}
181
182// Write the integrity report to stdout or a requested output file.
183fn write_report(
184    options: &BackupVerifyOptions,
185    report: &BackupIntegrityReport,
186) -> Result<(), BackupCommandError> {
187    if let Some(path) = &options.out {
188        let data = serde_json::to_vec_pretty(report)?;
189        fs::write(path, data)?;
190        return Ok(());
191    }
192
193    let stdout = io::stdout();
194    let mut handle = stdout.lock();
195    serde_json::to_writer_pretty(&mut handle, report)?;
196    writeln!(handle)?;
197    Ok(())
198}
199
200// Read the next required option value.
201fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, BackupCommandError>
202where
203    I: Iterator<Item = OsString>,
204{
205    args.next()
206        .and_then(|value| value.into_string().ok())
207        .ok_or(BackupCommandError::MissingValue(option))
208}
209
210// Return backup command usage text.
211const fn usage() -> &'static str {
212    "usage: canic backup status --dir <backup-dir> [--out <file>]\n       canic backup verify --dir <backup-dir> [--out <file>]"
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use canic_backup::{
219        artifacts::ArtifactChecksum,
220        journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
221        manifest::{
222            BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetBackupManifest,
223            FleetMember, FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
224            VerificationCheck, VerificationPlan,
225        },
226    };
227    use std::{
228        fs,
229        path::Path,
230        time::{SystemTime, UNIX_EPOCH},
231    };
232
233    const ROOT: &str = "aaaaa-aa";
234    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
235
236    // Ensure backup verification options parse the intended command shape.
237    #[test]
238    fn parses_backup_verify_options() {
239        let options = BackupVerifyOptions::parse([
240            OsString::from("--dir"),
241            OsString::from("backups/run"),
242            OsString::from("--out"),
243            OsString::from("report.json"),
244        ])
245        .expect("parse options");
246
247        assert_eq!(options.dir, PathBuf::from("backups/run"));
248        assert_eq!(options.out, Some(PathBuf::from("report.json")));
249    }
250
251    // Ensure backup status options parse the intended command shape.
252    #[test]
253    fn parses_backup_status_options() {
254        let options = BackupStatusOptions::parse([
255            OsString::from("--dir"),
256            OsString::from("backups/run"),
257            OsString::from("--out"),
258            OsString::from("status.json"),
259        ])
260        .expect("parse options");
261
262        assert_eq!(options.dir, PathBuf::from("backups/run"));
263        assert_eq!(options.out, Some(PathBuf::from("status.json")));
264    }
265
266    // Ensure backup status reads the journal and reports resume actions.
267    #[test]
268    fn backup_status_reads_journal_resume_report() {
269        let root = temp_dir("canic-cli-backup-status");
270        let layout = BackupLayout::new(root.clone());
271        layout
272            .write_journal(&journal_with_checksum(HASH.to_string()))
273            .expect("write journal");
274
275        let options = BackupStatusOptions {
276            dir: root.clone(),
277            out: None,
278        };
279        let report = backup_status(&options).expect("read backup status");
280
281        fs::remove_dir_all(root).expect("remove temp root");
282        assert_eq!(report.backup_id, "backup-test");
283        assert_eq!(report.total_artifacts, 1);
284        assert!(report.is_complete);
285        assert_eq!(report.pending_artifacts, 0);
286        assert_eq!(report.counts.skip, 1);
287    }
288
289    // Ensure the CLI verification path reads a layout and returns an integrity report.
290    #[test]
291    fn verify_backup_reads_layout_and_artifacts() {
292        let root = temp_dir("canic-cli-backup-verify");
293        let layout = BackupLayout::new(root.clone());
294        let checksum = write_artifact(&root, b"root artifact");
295
296        layout
297            .write_manifest(&valid_manifest())
298            .expect("write manifest");
299        layout
300            .write_journal(&journal_with_checksum(checksum.hash.clone()))
301            .expect("write journal");
302
303        let options = BackupVerifyOptions {
304            dir: root.clone(),
305            out: None,
306        };
307        let report = verify_backup(&options).expect("verify backup");
308
309        fs::remove_dir_all(root).expect("remove temp root");
310        assert_eq!(report.backup_id, "backup-test");
311        assert!(report.verified);
312        assert_eq!(report.durable_artifacts, 1);
313        assert_eq!(report.artifacts[0].checksum, checksum.hash);
314    }
315
316    // Build one valid manifest for CLI verification tests.
317    fn valid_manifest() -> FleetBackupManifest {
318        FleetBackupManifest {
319            manifest_version: 1,
320            backup_id: "backup-test".to_string(),
321            created_at: "2026-05-03T00:00:00Z".to_string(),
322            tool: ToolMetadata {
323                name: "canic".to_string(),
324                version: "0.30.3".to_string(),
325            },
326            source: SourceMetadata {
327                environment: "local".to_string(),
328                root_canister: ROOT.to_string(),
329            },
330            consistency: ConsistencySection {
331                mode: ConsistencyMode::CrashConsistent,
332                backup_units: vec![BackupUnit {
333                    unit_id: "fleet".to_string(),
334                    kind: BackupUnitKind::SubtreeRooted,
335                    roles: vec!["root".to_string()],
336                    consistency_reason: None,
337                    dependency_closure: Vec::new(),
338                    topology_validation: "subtree-closed".to_string(),
339                    quiescence_strategy: None,
340                }],
341            },
342            fleet: FleetSection {
343                topology_hash_algorithm: "sha256".to_string(),
344                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
345                discovery_topology_hash: HASH.to_string(),
346                pre_snapshot_topology_hash: HASH.to_string(),
347                topology_hash: HASH.to_string(),
348                members: vec![fleet_member()],
349            },
350            verification: VerificationPlan::default(),
351        }
352    }
353
354    // Build one valid manifest member.
355    fn fleet_member() -> FleetMember {
356        FleetMember {
357            role: "root".to_string(),
358            canister_id: ROOT.to_string(),
359            parent_canister_id: None,
360            subnet_canister_id: Some(ROOT.to_string()),
361            controller_hint: None,
362            identity_mode: IdentityMode::Fixed,
363            restore_group: 1,
364            verification_class: "basic".to_string(),
365            verification_checks: vec![VerificationCheck {
366                kind: "status".to_string(),
367                method: None,
368                roles: vec!["root".to_string()],
369            }],
370            source_snapshot: SourceSnapshot {
371                snapshot_id: "root-snapshot".to_string(),
372                module_hash: None,
373                wasm_hash: None,
374                code_version: Some("v0.30.3".to_string()),
375                artifact_path: "artifacts/root".to_string(),
376                checksum_algorithm: "sha256".to_string(),
377            },
378        }
379    }
380
381    // Build one durable journal with a caller-provided checksum.
382    fn journal_with_checksum(checksum: String) -> DownloadJournal {
383        DownloadJournal {
384            journal_version: 1,
385            backup_id: "backup-test".to_string(),
386            artifacts: vec![ArtifactJournalEntry {
387                canister_id: ROOT.to_string(),
388                snapshot_id: "root-snapshot".to_string(),
389                state: ArtifactState::Durable,
390                temp_path: None,
391                artifact_path: "artifacts/root".to_string(),
392                checksum_algorithm: "sha256".to_string(),
393                checksum: Some(checksum),
394                updated_at: "2026-05-03T00:00:00Z".to_string(),
395            }],
396        }
397    }
398
399    // Write one artifact at the layout-relative path used by test journals.
400    fn write_artifact(root: &Path, bytes: &[u8]) -> ArtifactChecksum {
401        let path = root.join("artifacts/root");
402        fs::create_dir_all(path.parent().expect("artifact has parent")).expect("create artifacts");
403        fs::write(&path, bytes).expect("write artifact");
404        ArtifactChecksum::from_bytes(bytes)
405    }
406
407    // Build a unique temporary directory.
408    fn temp_dir(prefix: &str) -> PathBuf {
409        let nanos = SystemTime::now()
410            .duration_since(UNIX_EPOCH)
411            .expect("system time after epoch")
412            .as_nanos();
413        std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
414    }
415}