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(
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#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct BackupVerifyOptions {
56 pub dir: PathBuf,
57 pub out: Option<PathBuf>,
58}
59
60impl BackupVerifyOptions {
61 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#[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 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
132pub 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
161pub 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
170fn 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
186pub 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
194fn 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
212fn 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
230fn 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
240const 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 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}