1use canic_backup::manifest::{
2 BackupUnitKind, ConsistencyMode, FleetBackupManifest, ManifestValidationError,
3};
4use serde_json::json;
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-v1 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-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 _ => Err(ManifestCommandError::UnknownOption(command)),
112 }
113}
114
115pub fn validate_manifest(
117 options: &ManifestValidateOptions,
118) -> Result<FleetBackupManifest, ManifestCommandError> {
119 let data = fs::read_to_string(&options.manifest)?;
120 let manifest: FleetBackupManifest = serde_json::from_str(&data)?;
121 manifest.validate()?;
122 Ok(manifest)
123}
124
125fn write_validation_summary(
127 options: &ManifestValidateOptions,
128 manifest: &FleetBackupManifest,
129) -> Result<(), ManifestCommandError> {
130 let summary = manifest_validation_summary(manifest);
131
132 if let Some(path) = &options.out {
133 let data = serde_json::to_vec_pretty(&summary)?;
134 fs::write(path, data)?;
135 return Ok(());
136 }
137
138 let stdout = io::stdout();
139 let mut handle = stdout.lock();
140 serde_json::to_writer_pretty(&mut handle, &summary)?;
141 writeln!(handle)?;
142 Ok(())
143}
144
145fn manifest_validation_summary(manifest: &FleetBackupManifest) -> serde_json::Value {
147 let design_conformance = manifest.design_conformance_report();
148
149 json!({
150 "status": "valid",
151 "backup_id": manifest.backup_id,
152 "members": manifest.fleet.members.len(),
153 "backup_unit_count": manifest.consistency.backup_units.len(),
154 "consistency_mode": consistency_mode_name(&manifest.consistency.mode),
155 "topology_hash": manifest.fleet.topology_hash,
156 "topology_hash_algorithm": manifest.fleet.topology_hash_algorithm,
157 "topology_hash_input": manifest.fleet.topology_hash_input,
158 "topology_validation_status": "validated",
159 "design_conformance": design_conformance,
160 "backup_unit_kinds": backup_unit_kind_counts(manifest),
161 "backup_units": manifest
162 .consistency
163 .backup_units
164 .iter()
165 .map(|unit| json!({
166 "unit_id": unit.unit_id,
167 "kind": backup_unit_kind_name(&unit.kind),
168 "role_count": unit.roles.len(),
169 "dependency_count": unit.dependency_closure.len(),
170 "topology_validation": unit.topology_validation,
171 }))
172 .collect::<Vec<_>>(),
173 })
174}
175
176fn require_design_conformance(
178 options: &ManifestValidateOptions,
179 manifest: &FleetBackupManifest,
180) -> Result<(), ManifestCommandError> {
181 if !options.require_design_v1 {
182 return Ok(());
183 }
184
185 let report = manifest.design_conformance_report();
186 if report.design_v1_ready {
187 Ok(())
188 } else {
189 Err(ManifestCommandError::DesignConformanceNotReady {
190 backup_id: manifest.backup_id.clone(),
191 })
192 }
193}
194
195fn backup_unit_kind_counts(manifest: &FleetBackupManifest) -> serde_json::Value {
197 let mut whole_fleet = 0;
198 let mut control_plane_subset = 0;
199 let mut subtree_rooted = 0;
200 let mut flat = 0;
201 for unit in &manifest.consistency.backup_units {
202 match &unit.kind {
203 BackupUnitKind::WholeFleet => whole_fleet += 1,
204 BackupUnitKind::ControlPlaneSubset => control_plane_subset += 1,
205 BackupUnitKind::SubtreeRooted => subtree_rooted += 1,
206 BackupUnitKind::Flat => flat += 1,
207 }
208 }
209
210 json!({
211 "whole_fleet": whole_fleet,
212 "control_plane_subset": control_plane_subset,
213 "subtree_rooted": subtree_rooted,
214 "flat": flat,
215 })
216}
217
218const fn consistency_mode_name(mode: &ConsistencyMode) -> &'static str {
220 match mode {
221 ConsistencyMode::CrashConsistent => "crash-consistent",
222 ConsistencyMode::QuiescedUnit => "quiesced-unit",
223 }
224}
225
226const fn backup_unit_kind_name(kind: &BackupUnitKind) -> &'static str {
228 match kind {
229 BackupUnitKind::WholeFleet => "whole-fleet",
230 BackupUnitKind::ControlPlaneSubset => "control-plane-subset",
231 BackupUnitKind::SubtreeRooted => "subtree-rooted",
232 BackupUnitKind::Flat => "flat",
233 }
234}
235
236fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, ManifestCommandError>
238where
239 I: Iterator<Item = OsString>,
240{
241 args.next()
242 .and_then(|value| value.into_string().ok())
243 .ok_or(ManifestCommandError::MissingValue(option))
244}
245
246const fn usage() -> &'static str {
248 "usage: canic manifest validate --manifest <file> [--out <file>] [--require-design-v1]"
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use canic_backup::manifest::{
255 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember, FleetSection,
256 IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
257 VerificationPlan,
258 };
259 use std::time::{SystemTime, UNIX_EPOCH};
260
261 const ROOT: &str = "aaaaa-aa";
262 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
263
264 #[test]
266 fn parses_manifest_validate_options() {
267 let options = ManifestValidateOptions::parse([
268 OsString::from("--manifest"),
269 OsString::from("manifest.json"),
270 OsString::from("--out"),
271 OsString::from("summary.json"),
272 OsString::from("--require-design-v1"),
273 ])
274 .expect("parse options");
275
276 assert_eq!(options.manifest, PathBuf::from("manifest.json"));
277 assert_eq!(options.out, Some(PathBuf::from("summary.json")));
278 assert!(options.require_design_v1);
279 }
280
281 #[test]
283 fn validate_manifest_reads_and_validates_manifest() {
284 let root = temp_dir("canic-cli-manifest-validate");
285 fs::create_dir_all(&root).expect("create temp root");
286 let manifest_path = root.join("manifest.json");
287
288 fs::write(
289 &manifest_path,
290 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
291 )
292 .expect("write manifest");
293
294 let options = ManifestValidateOptions {
295 manifest: manifest_path,
296 out: None,
297 require_design_v1: false,
298 };
299
300 let manifest = validate_manifest(&options).expect("validate manifest");
301
302 fs::remove_dir_all(root).expect("remove temp root");
303 assert_eq!(manifest.backup_id, "backup-test");
304 assert_eq!(manifest.fleet.members.len(), 1);
305 }
306
307 #[test]
309 fn write_validation_summary_writes_out_file() {
310 let root = temp_dir("canic-cli-manifest-summary");
311 fs::create_dir_all(&root).expect("create temp root");
312 let out = root.join("summary.json");
313 let options = ManifestValidateOptions {
314 manifest: root.join("manifest.json"),
315 out: Some(out.clone()),
316 require_design_v1: false,
317 };
318
319 write_validation_summary(&options, &valid_manifest()).expect("write summary");
320 let summary: serde_json::Value =
321 serde_json::from_slice(&fs::read(&out).expect("read summary")).expect("parse summary");
322
323 fs::remove_dir_all(root).expect("remove temp root");
324 assert_eq!(summary["status"], "valid");
325 assert_eq!(summary["backup_id"], "backup-test");
326 assert_eq!(summary["members"], 1);
327 assert_eq!(summary["backup_unit_count"], 1);
328 assert_eq!(summary["consistency_mode"], "crash-consistent");
329 assert_eq!(summary["topology_validation_status"], "validated");
330 assert_eq!(summary["backup_unit_kinds"]["subtree_rooted"], 1);
331 assert_eq!(summary["backup_units"][0]["unit_id"], "fleet");
332 assert_eq!(summary["backup_units"][0]["kind"], "subtree-rooted");
333 assert_eq!(summary["backup_units"][0]["role_count"], 1);
334 assert_eq!(summary["design_conformance"]["design_v1_ready"], true);
335 assert_eq!(
336 summary["design_conformance"]["topology"]["canonical_input"],
337 true
338 );
339 assert_eq!(
340 summary["design_conformance"]["snapshot_provenance"]["all_members_have_checksum"],
341 true
342 );
343 }
344
345 #[test]
347 fn require_design_v1_fails_after_writing_summary() {
348 let root = temp_dir("canic-cli-manifest-design-v1");
349 fs::create_dir_all(&root).expect("create temp root");
350 let out = root.join("summary.json");
351 let mut manifest = valid_manifest();
352 manifest.fleet.topology_hash_input = "legacy-input".to_string();
353 let options = ManifestValidateOptions {
354 manifest: root.join("manifest.json"),
355 out: Some(out.clone()),
356 require_design_v1: true,
357 };
358
359 write_validation_summary(&options, &manifest).expect("write summary");
360 let err = require_design_conformance(&options, &manifest)
361 .expect_err("design-v1 gate should fail");
362 let summary: serde_json::Value =
363 serde_json::from_slice(&fs::read(&out).expect("read summary")).expect("parse summary");
364
365 fs::remove_dir_all(root).expect("remove temp root");
366 assert!(matches!(
367 err,
368 ManifestCommandError::DesignConformanceNotReady { .. }
369 ));
370 assert_eq!(summary["design_conformance"]["design_v1_ready"], false);
371 assert_eq!(
372 summary["design_conformance"]["topology"]["canonical_input"],
373 false
374 );
375 }
376
377 fn valid_manifest() -> FleetBackupManifest {
379 FleetBackupManifest {
380 manifest_version: 1,
381 backup_id: "backup-test".to_string(),
382 created_at: "2026-05-03T00:00:00Z".to_string(),
383 tool: ToolMetadata {
384 name: "canic".to_string(),
385 version: "0.30.1".to_string(),
386 },
387 source: SourceMetadata {
388 environment: "local".to_string(),
389 root_canister: ROOT.to_string(),
390 },
391 consistency: ConsistencySection {
392 mode: ConsistencyMode::CrashConsistent,
393 backup_units: vec![BackupUnit {
394 unit_id: "fleet".to_string(),
395 kind: BackupUnitKind::SubtreeRooted,
396 roles: vec!["root".to_string()],
397 consistency_reason: None,
398 dependency_closure: Vec::new(),
399 topology_validation: "subtree-closed".to_string(),
400 quiescence_strategy: None,
401 }],
402 },
403 fleet: FleetSection {
404 topology_hash_algorithm: "sha256".to_string(),
405 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
406 discovery_topology_hash: HASH.to_string(),
407 pre_snapshot_topology_hash: HASH.to_string(),
408 topology_hash: HASH.to_string(),
409 members: vec![fleet_member()],
410 },
411 verification: VerificationPlan::default(),
412 }
413 }
414
415 fn fleet_member() -> FleetMember {
417 FleetMember {
418 role: "root".to_string(),
419 canister_id: ROOT.to_string(),
420 parent_canister_id: None,
421 subnet_canister_id: Some(ROOT.to_string()),
422 controller_hint: None,
423 identity_mode: IdentityMode::Fixed,
424 restore_group: 1,
425 verification_class: "basic".to_string(),
426 verification_checks: vec![VerificationCheck {
427 kind: "status".to_string(),
428 method: None,
429 roles: vec!["root".to_string()],
430 }],
431 source_snapshot: SourceSnapshot {
432 snapshot_id: "root-snapshot".to_string(),
433 module_hash: None,
434 wasm_hash: None,
435 code_version: Some("v0.30.1".to_string()),
436 artifact_path: "artifacts/root".to_string(),
437 checksum_algorithm: "sha256".to_string(),
438 checksum: Some(HASH.to_string()),
439 },
440 }
441 }
442
443 fn temp_dir(prefix: &str) -> PathBuf {
445 let nanos = SystemTime::now()
446 .duration_since(UNIX_EPOCH)
447 .expect("system time after epoch")
448 .as_nanos();
449 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
450 }
451}