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