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#[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#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct BackupVerifyOptions {
47 pub dir: PathBuf,
48 pub out: Option<PathBuf>,
49}
50
51impl BackupVerifyOptions {
52 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#[derive(Clone, Debug, Eq, PartialEq)]
85pub struct BackupStatusOptions {
86 pub dir: PathBuf,
87 pub out: Option<PathBuf>,
88}
89
90impl BackupStatusOptions {
91 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
119pub 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
147pub 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
156pub 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
164fn 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
182fn 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
200fn 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
210const 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 #[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 #[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 #[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 #[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 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 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 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 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 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}