1use crate::{
2 args::{parse_matches, path_option, value_arg},
3 output, version_text,
4};
5use canic_backup::manifest::{
6 FleetBackupManifest, ManifestValidationError, manifest_validation_summary,
7};
8use clap::Command as ClapCommand;
9use std::{ffi::OsString, fs, path::PathBuf};
10use thiserror::Error as ThisError;
11
12#[derive(Debug, ThisError)]
17pub enum ManifestCommandError {
18 #[error("{0}")]
19 Usage(&'static str),
20
21 #[error("missing required option {0}")]
22 MissingOption(&'static str),
23
24 #[error("unknown option {0}")]
25 UnknownOption(String),
26
27 #[error(transparent)]
28 Io(#[from] std::io::Error),
29
30 #[error(transparent)]
31 Json(#[from] serde_json::Error),
32
33 #[error(transparent)]
34 InvalidManifest(#[from] ManifestValidationError),
35}
36
37#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct ManifestValidateOptions {
43 pub manifest: PathBuf,
44 pub out: Option<PathBuf>,
45}
46
47impl ManifestValidateOptions {
48 pub fn parse<I>(args: I) -> Result<Self, ManifestCommandError>
50 where
51 I: IntoIterator<Item = OsString>,
52 {
53 let matches = parse_matches(manifest_validate_command(), args)
54 .map_err(|_| ManifestCommandError::Usage(usage()))?;
55
56 Ok(Self {
57 manifest: path_option(&matches, "manifest")
58 .ok_or(ManifestCommandError::MissingOption("--manifest"))?,
59 out: path_option(&matches, "out"),
60 })
61 }
62}
63
64fn manifest_validate_command() -> ClapCommand {
66 ClapCommand::new("manifest-validate")
67 .disable_help_flag(true)
68 .arg(value_arg("manifest").long("manifest"))
69 .arg(value_arg("out").long("out"))
70}
71
72pub fn run<I>(args: I) -> Result<(), ManifestCommandError>
74where
75 I: IntoIterator<Item = OsString>,
76{
77 let mut args = args.into_iter();
78 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
79 return Err(ManifestCommandError::Usage(usage()));
80 };
81
82 match command.as_str() {
83 "validate" => {
84 let options = ManifestValidateOptions::parse(args)?;
85 let manifest = validate_manifest(&options)?;
86 write_validation_summary(&options, &manifest)?;
87 Ok(())
88 }
89 "help" | "--help" | "-h" => {
90 println!("{}", usage());
91 Ok(())
92 }
93 "version" | "--version" | "-V" => {
94 println!("{}", version_text());
95 Ok(())
96 }
97 _ => Err(ManifestCommandError::UnknownOption(command)),
98 }
99}
100
101pub fn validate_manifest(
103 options: &ManifestValidateOptions,
104) -> Result<FleetBackupManifest, ManifestCommandError> {
105 let data = fs::read_to_string(&options.manifest)?;
106 let manifest: FleetBackupManifest = serde_json::from_str(&data)?;
107 manifest.validate()?;
108 Ok(manifest)
109}
110
111fn write_validation_summary(
113 options: &ManifestValidateOptions,
114 manifest: &FleetBackupManifest,
115) -> Result<(), ManifestCommandError> {
116 let summary = manifest_validation_summary(manifest);
117
118 output::write_pretty_json(options.out.as_ref(), &summary)
119}
120
121const fn usage() -> &'static str {
123 "usage: canic manifest validate --manifest <file> [--out <file>]"
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use canic_backup::manifest::{
130 BackupUnit, BackupUnitKind, ConsistencySection, FleetMember, FleetSection, IdentityMode,
131 SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck, VerificationPlan,
132 };
133 use std::time::{SystemTime, UNIX_EPOCH};
134
135 const ROOT: &str = "aaaaa-aa";
136 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
137
138 #[test]
140 fn parses_manifest_validate_options() {
141 let options = ManifestValidateOptions::parse([
142 OsString::from("--manifest"),
143 OsString::from("manifest.json"),
144 OsString::from("--out"),
145 OsString::from("summary.json"),
146 ])
147 .expect("parse options");
148
149 assert_eq!(options.manifest, PathBuf::from("manifest.json"));
150 assert_eq!(options.out, Some(PathBuf::from("summary.json")));
151 }
152
153 #[test]
155 fn validate_manifest_reads_and_validates_manifest() {
156 let root = temp_dir("canic-cli-manifest-validate");
157 fs::create_dir_all(&root).expect("create temp root");
158 let manifest_path = root.join("manifest.json");
159
160 fs::write(
161 &manifest_path,
162 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
163 )
164 .expect("write manifest");
165
166 let options = ManifestValidateOptions {
167 manifest: manifest_path,
168 out: None,
169 };
170
171 let manifest = validate_manifest(&options).expect("validate manifest");
172
173 fs::remove_dir_all(root).expect("remove temp root");
174 assert_eq!(manifest.backup_id, "backup-test");
175 assert_eq!(manifest.fleet.members.len(), 1);
176 }
177
178 #[test]
180 fn write_validation_summary_writes_out_file() {
181 let root = temp_dir("canic-cli-manifest-summary");
182 fs::create_dir_all(&root).expect("create temp root");
183 let out = root.join("summary.json");
184 let options = ManifestValidateOptions {
185 manifest: root.join("manifest.json"),
186 out: Some(out.clone()),
187 };
188
189 write_validation_summary(&options, &valid_manifest()).expect("write summary");
190 let summary: serde_json::Value =
191 serde_json::from_slice(&fs::read(&out).expect("read summary")).expect("parse summary");
192
193 fs::remove_dir_all(root).expect("remove temp root");
194 assert_eq!(summary["status"], "valid");
195 assert_eq!(summary["backup_id"], "backup-test");
196 assert_eq!(summary["members"], 1);
197 assert_eq!(summary["backup_unit_count"], 1);
198 assert_eq!(summary["topology_validation_status"], "validated");
199 assert_eq!(summary["backup_unit_kinds"]["subtree"], 1);
200 assert_eq!(summary["backup_units"][0]["unit_id"], "fleet");
201 assert_eq!(summary["backup_units"][0]["kind"], "subtree");
202 assert_eq!(summary["backup_units"][0]["role_count"], 1);
203 }
204
205 fn valid_manifest() -> FleetBackupManifest {
207 FleetBackupManifest {
208 manifest_version: 1,
209 backup_id: "backup-test".to_string(),
210 created_at: "2026-05-03T00:00:00Z".to_string(),
211 tool: ToolMetadata {
212 name: "canic".to_string(),
213 version: "0.30.1".to_string(),
214 },
215 source: SourceMetadata {
216 environment: "local".to_string(),
217 root_canister: ROOT.to_string(),
218 },
219 consistency: ConsistencySection {
220 backup_units: vec![BackupUnit {
221 unit_id: "fleet".to_string(),
222 kind: BackupUnitKind::Subtree,
223 roles: vec!["root".to_string()],
224 }],
225 },
226 fleet: FleetSection {
227 topology_hash_algorithm: "sha256".to_string(),
228 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
229 discovery_topology_hash: HASH.to_string(),
230 pre_snapshot_topology_hash: HASH.to_string(),
231 topology_hash: HASH.to_string(),
232 members: vec![fleet_member()],
233 },
234 verification: VerificationPlan::default(),
235 }
236 }
237
238 fn fleet_member() -> FleetMember {
240 FleetMember {
241 role: "root".to_string(),
242 canister_id: ROOT.to_string(),
243 parent_canister_id: None,
244 subnet_canister_id: Some(ROOT.to_string()),
245 controller_hint: None,
246 identity_mode: IdentityMode::Fixed,
247 verification_checks: vec![VerificationCheck {
248 kind: "status".to_string(),
249 roles: vec!["root".to_string()],
250 }],
251 source_snapshot: SourceSnapshot {
252 snapshot_id: "root-snapshot".to_string(),
253 module_hash: None,
254 wasm_hash: None,
255 code_version: Some("v0.30.1".to_string()),
256 artifact_path: "artifacts/root".to_string(),
257 checksum_algorithm: "sha256".to_string(),
258 checksum: Some(HASH.to_string()),
259 },
260 }
261 }
262
263 fn temp_dir(prefix: &str) -> PathBuf {
265 let nanos = SystemTime::now()
266 .duration_since(UNIX_EPOCH)
267 .expect("system time after epoch")
268 .as_nanos();
269 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
270 }
271}