1use crate::version_text;
2use canic_backup::manifest::{
3 FleetBackupManifest, ManifestValidationError, manifest_validation_summary,
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 ManifestCommandError {
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 InvalidManifest(#[from] ManifestValidationError),
39
40 #[error("manifest {backup_id} is not design ready")]
41 DesignConformanceNotReady { backup_id: String },
42}
43
44#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct ManifestValidateOptions {
50 pub manifest: PathBuf,
51 pub out: Option<PathBuf>,
52 pub require_design_v1: bool,
53}
54
55impl ManifestValidateOptions {
56 pub fn parse<I>(args: I) -> Result<Self, ManifestCommandError>
58 where
59 I: IntoIterator<Item = OsString>,
60 {
61 let mut manifest = None;
62 let mut out = None;
63 let mut require_design_v1 = false;
64
65 let mut args = args.into_iter();
66 while let Some(arg) = args.next() {
67 let arg = arg
68 .into_string()
69 .map_err(|_| ManifestCommandError::Usage(usage()))?;
70 match arg.as_str() {
71 "--manifest" => {
72 manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
73 }
74 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
75 "--require-design" | "--require-design-v1" => require_design_v1 = true,
76 "--help" | "-h" => return Err(ManifestCommandError::Usage(usage())),
77 _ => return Err(ManifestCommandError::UnknownOption(arg)),
78 }
79 }
80
81 Ok(Self {
82 manifest: manifest.ok_or(ManifestCommandError::MissingOption("--manifest"))?,
83 out,
84 require_design_v1,
85 })
86 }
87}
88
89pub fn run<I>(args: I) -> Result<(), ManifestCommandError>
91where
92 I: IntoIterator<Item = OsString>,
93{
94 let mut args = args.into_iter();
95 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
96 return Err(ManifestCommandError::Usage(usage()));
97 };
98
99 match command.as_str() {
100 "validate" => {
101 let options = ManifestValidateOptions::parse(args)?;
102 let manifest = validate_manifest(&options)?;
103 write_validation_summary(&options, &manifest)?;
104 require_design_conformance(&options, &manifest)?;
105 Ok(())
106 }
107 "help" | "--help" | "-h" => {
108 println!("{}", usage());
109 Ok(())
110 }
111 "version" | "--version" | "-V" => {
112 println!("{}", version_text());
113 Ok(())
114 }
115 _ => Err(ManifestCommandError::UnknownOption(command)),
116 }
117}
118
119pub fn validate_manifest(
121 options: &ManifestValidateOptions,
122) -> Result<FleetBackupManifest, ManifestCommandError> {
123 let data = fs::read_to_string(&options.manifest)?;
124 let manifest: FleetBackupManifest = serde_json::from_str(&data)?;
125 manifest.validate()?;
126 Ok(manifest)
127}
128
129fn write_validation_summary(
131 options: &ManifestValidateOptions,
132 manifest: &FleetBackupManifest,
133) -> Result<(), ManifestCommandError> {
134 let summary = manifest_validation_summary(manifest);
135
136 if let Some(path) = &options.out {
137 let data = serde_json::to_vec_pretty(&summary)?;
138 fs::write(path, data)?;
139 return Ok(());
140 }
141
142 let stdout = io::stdout();
143 let mut handle = stdout.lock();
144 serde_json::to_writer_pretty(&mut handle, &summary)?;
145 writeln!(handle)?;
146 Ok(())
147}
148
149fn require_design_conformance(
151 options: &ManifestValidateOptions,
152 manifest: &FleetBackupManifest,
153) -> Result<(), ManifestCommandError> {
154 if !options.require_design_v1 {
155 return Ok(());
156 }
157
158 let report = manifest.design_conformance_report();
159 if report.design_v1_ready {
160 Ok(())
161 } else {
162 Err(ManifestCommandError::DesignConformanceNotReady {
163 backup_id: manifest.backup_id.clone(),
164 })
165 }
166}
167
168fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, ManifestCommandError>
170where
171 I: Iterator<Item = OsString>,
172{
173 args.next()
174 .and_then(|value| value.into_string().ok())
175 .ok_or(ManifestCommandError::MissingValue(option))
176}
177
178const fn usage() -> &'static str {
180 "usage: canic manifest validate --manifest <file> [--out <file>] [--require-design]"
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use canic_backup::manifest::{
187 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember, FleetSection,
188 IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
189 VerificationPlan,
190 };
191 use std::time::{SystemTime, UNIX_EPOCH};
192
193 const ROOT: &str = "aaaaa-aa";
194 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
195
196 #[test]
198 fn parses_manifest_validate_options() {
199 let options = ManifestValidateOptions::parse([
200 OsString::from("--manifest"),
201 OsString::from("manifest.json"),
202 OsString::from("--out"),
203 OsString::from("summary.json"),
204 OsString::from("--require-design"),
205 ])
206 .expect("parse options");
207
208 assert_eq!(options.manifest, PathBuf::from("manifest.json"));
209 assert_eq!(options.out, Some(PathBuf::from("summary.json")));
210 assert!(options.require_design_v1);
211 }
212
213 #[test]
215 fn validate_manifest_reads_and_validates_manifest() {
216 let root = temp_dir("canic-cli-manifest-validate");
217 fs::create_dir_all(&root).expect("create temp root");
218 let manifest_path = root.join("manifest.json");
219
220 fs::write(
221 &manifest_path,
222 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
223 )
224 .expect("write manifest");
225
226 let options = ManifestValidateOptions {
227 manifest: manifest_path,
228 out: None,
229 require_design_v1: false,
230 };
231
232 let manifest = validate_manifest(&options).expect("validate manifest");
233
234 fs::remove_dir_all(root).expect("remove temp root");
235 assert_eq!(manifest.backup_id, "backup-test");
236 assert_eq!(manifest.fleet.members.len(), 1);
237 }
238
239 #[test]
241 fn write_validation_summary_writes_out_file() {
242 let root = temp_dir("canic-cli-manifest-summary");
243 fs::create_dir_all(&root).expect("create temp root");
244 let out = root.join("summary.json");
245 let options = ManifestValidateOptions {
246 manifest: root.join("manifest.json"),
247 out: Some(out.clone()),
248 require_design_v1: false,
249 };
250
251 write_validation_summary(&options, &valid_manifest()).expect("write summary");
252 let summary: serde_json::Value =
253 serde_json::from_slice(&fs::read(&out).expect("read summary")).expect("parse summary");
254
255 fs::remove_dir_all(root).expect("remove temp root");
256 assert_eq!(summary["status"], "valid");
257 assert_eq!(summary["backup_id"], "backup-test");
258 assert_eq!(summary["members"], 1);
259 assert_eq!(summary["backup_unit_count"], 1);
260 assert_eq!(summary["consistency_mode"], "crash-consistent");
261 assert_eq!(summary["topology_validation_status"], "validated");
262 assert_eq!(summary["backup_unit_kinds"]["subtree_rooted"], 1);
263 assert_eq!(summary["backup_units"][0]["unit_id"], "fleet");
264 assert_eq!(summary["backup_units"][0]["kind"], "subtree-rooted");
265 assert_eq!(summary["backup_units"][0]["role_count"], 1);
266 assert_eq!(summary["design_conformance"]["design_v1_ready"], true);
267 assert_eq!(
268 summary["design_conformance"]["topology"]["canonical_input"],
269 true
270 );
271 assert_eq!(
272 summary["design_conformance"]["snapshot_provenance"]["all_members_have_checksum"],
273 true
274 );
275 }
276
277 #[test]
279 fn require_design_v1_fails_after_writing_summary() {
280 let root = temp_dir("canic-cli-manifest-design");
281 fs::create_dir_all(&root).expect("create temp root");
282 let out = root.join("summary.json");
283 let mut manifest = valid_manifest();
284 manifest.fleet.topology_hash_input = "legacy-input".to_string();
285 let options = ManifestValidateOptions {
286 manifest: root.join("manifest.json"),
287 out: Some(out.clone()),
288 require_design_v1: true,
289 };
290
291 write_validation_summary(&options, &manifest).expect("write summary");
292 let err =
293 require_design_conformance(&options, &manifest).expect_err("design gate should fail");
294 let summary: serde_json::Value =
295 serde_json::from_slice(&fs::read(&out).expect("read summary")).expect("parse summary");
296
297 fs::remove_dir_all(root).expect("remove temp root");
298 assert!(matches!(
299 err,
300 ManifestCommandError::DesignConformanceNotReady { .. }
301 ));
302 assert_eq!(summary["design_conformance"]["design_v1_ready"], false);
303 assert_eq!(
304 summary["design_conformance"]["topology"]["canonical_input"],
305 false
306 );
307 }
308
309 fn valid_manifest() -> FleetBackupManifest {
311 FleetBackupManifest {
312 manifest_version: 1,
313 backup_id: "backup-test".to_string(),
314 created_at: "2026-05-03T00:00:00Z".to_string(),
315 tool: ToolMetadata {
316 name: "canic".to_string(),
317 version: "0.30.1".to_string(),
318 },
319 source: SourceMetadata {
320 environment: "local".to_string(),
321 root_canister: ROOT.to_string(),
322 },
323 consistency: ConsistencySection {
324 mode: ConsistencyMode::CrashConsistent,
325 backup_units: vec![BackupUnit {
326 unit_id: "fleet".to_string(),
327 kind: BackupUnitKind::SubtreeRooted,
328 roles: vec!["root".to_string()],
329 consistency_reason: None,
330 dependency_closure: Vec::new(),
331 topology_validation: "subtree-closed".to_string(),
332 quiescence_strategy: None,
333 }],
334 },
335 fleet: FleetSection {
336 topology_hash_algorithm: "sha256".to_string(),
337 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
338 discovery_topology_hash: HASH.to_string(),
339 pre_snapshot_topology_hash: HASH.to_string(),
340 topology_hash: HASH.to_string(),
341 members: vec![fleet_member()],
342 },
343 verification: VerificationPlan::default(),
344 }
345 }
346
347 fn fleet_member() -> FleetMember {
349 FleetMember {
350 role: "root".to_string(),
351 canister_id: ROOT.to_string(),
352 parent_canister_id: None,
353 subnet_canister_id: Some(ROOT.to_string()),
354 controller_hint: None,
355 identity_mode: IdentityMode::Fixed,
356 restore_group: 1,
357 verification_class: "basic".to_string(),
358 verification_checks: vec![VerificationCheck {
359 kind: "status".to_string(),
360 method: None,
361 roles: vec!["root".to_string()],
362 }],
363 source_snapshot: SourceSnapshot {
364 snapshot_id: "root-snapshot".to_string(),
365 module_hash: None,
366 wasm_hash: None,
367 code_version: Some("v0.30.1".to_string()),
368 artifact_path: "artifacts/root".to_string(),
369 checksum_algorithm: "sha256".to_string(),
370 checksum: Some(HASH.to_string()),
371 },
372 }
373 }
374
375 fn temp_dir(prefix: &str) -> PathBuf {
377 let nanos = SystemTime::now()
378 .duration_since(UNIX_EPOCH)
379 .expect("system time after epoch")
380 .as_nanos();
381 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
382 }
383}