1use crate::{output, restore as cli_restore, version_text};
2use canic_backup::{
3 journal::JournalResumeReport,
4 persistence::{
5 BackupInspectionReport, BackupIntegrityReport, BackupLayout, BackupProvenanceReport,
6 PersistenceError,
7 },
8 restore::RestorePlanError,
9};
10use serde::Serialize;
11use std::{ffi::OsString, path::PathBuf};
12use thiserror::Error as ThisError;
13
14mod options;
15mod preflight;
16mod smoke;
17
18pub use options::{
19 BackupInspectOptions, BackupPreflightOptions, BackupProvenanceOptions, BackupSmokeOptions,
20 BackupStatusOptions, BackupVerifyOptions,
21};
22pub use preflight::{BackupPreflightReport, backup_preflight};
23pub use smoke::{BackupSmokeReport, backup_smoke};
24
25#[derive(Debug, ThisError)]
30pub enum BackupCommandError {
31 #[error("{0}")]
32 Usage(&'static str),
33
34 #[error("missing required option {0}")]
35 MissingOption(&'static str),
36
37 #[error("unknown option {0}")]
38 UnknownOption(String),
39
40 #[error(
41 "backup journal {backup_id} is incomplete: {pending_artifacts}/{total_artifacts} artifacts still require resume work"
42 )]
43 IncompleteJournal {
44 backup_id: String,
45 total_artifacts: usize,
46 pending_artifacts: usize,
47 },
48
49 #[error(
50 "backup inspection {backup_id} is not ready for verification: backup_id_matches={backup_id_matches}, topology_receipts_match={topology_receipts_match}, journal_complete={journal_complete}, topology_mismatches={topology_mismatches}, missing={missing_artifacts}, unexpected={unexpected_artifacts}, path_mismatches={path_mismatches}, checksum_mismatches={checksum_mismatches}"
51 )]
52 InspectionNotReady {
53 backup_id: String,
54 backup_id_matches: bool,
55 topology_receipts_match: bool,
56 journal_complete: bool,
57 topology_mismatches: usize,
58 missing_artifacts: usize,
59 unexpected_artifacts: usize,
60 path_mismatches: usize,
61 checksum_mismatches: usize,
62 },
63
64 #[error(
65 "backup provenance {backup_id} is not consistent: backup_id_matches={backup_id_matches}, topology_receipts_match={topology_receipts_match}, topology_mismatches={topology_mismatches}"
66 )]
67 ProvenanceNotConsistent {
68 backup_id: String,
69 backup_id_matches: bool,
70 topology_receipts_match: bool,
71 topology_mismatches: usize,
72 },
73
74 #[error("restore plan for backup {backup_id} is not restore-ready: reasons={reasons:?}")]
75 RestoreNotReady {
76 backup_id: String,
77 reasons: Vec<String>,
78 },
79
80 #[error("backup manifest {backup_id} is not design ready")]
81 DesignConformanceNotReady { backup_id: String },
82
83 #[error(transparent)]
84 Io(#[from] std::io::Error),
85
86 #[error(transparent)]
87 Json(#[from] serde_json::Error),
88
89 #[error(transparent)]
90 Persistence(#[from] PersistenceError),
91
92 #[error(transparent)]
93 RestorePlan(#[from] RestorePlanError),
94
95 #[error(transparent)]
96 RestoreCli(#[from] cli_restore::RestoreCommandError),
97}
98
99pub fn run<I>(args: I) -> Result<(), BackupCommandError>
101where
102 I: IntoIterator<Item = OsString>,
103{
104 let mut args = args.into_iter();
105 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
106 return Err(BackupCommandError::Usage(usage()));
107 };
108
109 match command.as_str() {
110 "preflight" => {
111 let options = BackupPreflightOptions::parse(args)?;
112 backup_preflight(&options)?;
113 Ok(())
114 }
115 "smoke" => {
116 let options = BackupSmokeOptions::parse(args)?;
117 backup_smoke(&options)?;
118 Ok(())
119 }
120 "inspect" => {
121 let options = BackupInspectOptions::parse(args)?;
122 let report = inspect_backup(&options)?;
123 write_inspect_report(&options, &report)?;
124 enforce_inspection_requirements(&options, &report)?;
125 Ok(())
126 }
127 "provenance" => {
128 let options = BackupProvenanceOptions::parse(args)?;
129 let report = backup_provenance(&options)?;
130 write_provenance_report(&options, &report)?;
131 enforce_provenance_requirements(&options, &report)?;
132 Ok(())
133 }
134 "status" => {
135 let options = BackupStatusOptions::parse(args)?;
136 let report = backup_status(&options)?;
137 write_status_report(&options, &report)?;
138 enforce_status_requirements(&options, &report)?;
139 Ok(())
140 }
141 "verify" => {
142 let options = BackupVerifyOptions::parse(args)?;
143 let report = verify_backup(&options)?;
144 write_report(&options, &report)?;
145 Ok(())
146 }
147 "help" | "--help" | "-h" => {
148 println!("{}", usage());
149 Ok(())
150 }
151 "version" | "--version" | "-V" => {
152 println!("{}", version_text());
153 Ok(())
154 }
155 _ => Err(BackupCommandError::UnknownOption(command)),
156 }
157}
158
159pub fn inspect_backup(
161 options: &BackupInspectOptions,
162) -> Result<BackupInspectionReport, BackupCommandError> {
163 let layout = BackupLayout::new(options.dir.clone());
164 layout.inspect().map_err(BackupCommandError::from)
165}
166
167pub fn backup_provenance(
169 options: &BackupProvenanceOptions,
170) -> Result<BackupProvenanceReport, BackupCommandError> {
171 let layout = BackupLayout::new(options.dir.clone());
172 layout.provenance().map_err(BackupCommandError::from)
173}
174
175pub fn backup_status(
177 options: &BackupStatusOptions,
178) -> Result<JournalResumeReport, BackupCommandError> {
179 let layout = BackupLayout::new(options.dir.clone());
180 let journal = layout.read_journal()?;
181 Ok(journal.resume_report())
182}
183
184pub fn verify_backup(
186 options: &BackupVerifyOptions,
187) -> Result<BackupIntegrityReport, BackupCommandError> {
188 let layout = BackupLayout::new(options.dir.clone());
189 layout.verify_integrity().map_err(BackupCommandError::from)
190}
191
192fn enforce_provenance_requirements(
194 options: &BackupProvenanceOptions,
195 report: &BackupProvenanceReport,
196) -> Result<(), BackupCommandError> {
197 if !options.require_consistent || (report.backup_id_matches && report.topology_receipts_match) {
198 return Ok(());
199 }
200
201 Err(BackupCommandError::ProvenanceNotConsistent {
202 backup_id: report.backup_id.clone(),
203 backup_id_matches: report.backup_id_matches,
204 topology_receipts_match: report.topology_receipts_match,
205 topology_mismatches: report.topology_receipt_mismatches.len(),
206 })
207}
208
209fn enforce_inspection_requirements(
211 options: &BackupInspectOptions,
212 report: &BackupInspectionReport,
213) -> Result<(), BackupCommandError> {
214 if !options.require_ready || report.ready_for_verify {
215 return Ok(());
216 }
217
218 Err(BackupCommandError::InspectionNotReady {
219 backup_id: report.backup_id.clone(),
220 backup_id_matches: report.backup_id_matches,
221 topology_receipts_match: report.topology_receipt_mismatches.is_empty(),
222 journal_complete: report.journal_complete,
223 topology_mismatches: report.topology_receipt_mismatches.len(),
224 missing_artifacts: report.missing_journal_artifacts.len(),
225 unexpected_artifacts: report.unexpected_journal_artifacts.len(),
226 path_mismatches: report.path_mismatches.len(),
227 checksum_mismatches: report.checksum_mismatches.len(),
228 })
229}
230
231pub(super) fn ensure_complete_status(
233 report: &JournalResumeReport,
234) -> Result<(), BackupCommandError> {
235 if report.is_complete {
236 return Ok(());
237 }
238
239 Err(BackupCommandError::IncompleteJournal {
240 backup_id: report.backup_id.clone(),
241 total_artifacts: report.total_artifacts,
242 pending_artifacts: report.pending_artifacts,
243 })
244}
245
246fn enforce_status_requirements(
248 options: &BackupStatusOptions,
249 report: &JournalResumeReport,
250) -> Result<(), BackupCommandError> {
251 if !options.require_complete {
252 return Ok(());
253 }
254
255 ensure_complete_status(report)
256}
257
258fn write_status_report(
260 options: &BackupStatusOptions,
261 report: &JournalResumeReport,
262) -> Result<(), BackupCommandError> {
263 output::write_pretty_json(options.out.as_ref(), report)
264}
265
266fn write_inspect_report(
268 options: &BackupInspectOptions,
269 report: &BackupInspectionReport,
270) -> Result<(), BackupCommandError> {
271 output::write_pretty_json(options.out.as_ref(), report)
272}
273
274fn write_provenance_report(
276 options: &BackupProvenanceOptions,
277 report: &BackupProvenanceReport,
278) -> Result<(), BackupCommandError> {
279 output::write_pretty_json(options.out.as_ref(), report)
280}
281
282fn write_report(
284 options: &BackupVerifyOptions,
285 report: &BackupIntegrityReport,
286) -> Result<(), BackupCommandError> {
287 output::write_pretty_json(options.out.as_ref(), report)
288}
289
290pub(super) fn write_json_file<T>(path: &PathBuf, value: &T) -> Result<(), BackupCommandError>
292where
293 T: Serialize,
294{
295 output::write_pretty_json_file(path, value)
296}
297
298const fn usage() -> &'static str {
300 "usage: canic backup <command> [<args>]\n\ncommands:\n smoke Run the post-capture no-mutation smoke path.\n preflight Write the standard validation, integrity, plan, and status bundle.\n inspect Check manifest and journal agreement without reading artifact bytes.\n provenance Summarize backup source, topology, and artifact provenance.\n status Summarize resumable download journal state.\n verify Verify layout and durable artifact checksums."
301}
302
303#[cfg(test)]
304mod tests;