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