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}
47
48impl ManifestValidateOptions {
49 pub fn parse<I>(args: I) -> Result<Self, ManifestCommandError>
51 where
52 I: IntoIterator<Item = OsString>,
53 {
54 let mut manifest = None;
55
56 let mut args = args.into_iter();
57 while let Some(arg) = args.next() {
58 let arg = arg
59 .into_string()
60 .map_err(|_| ManifestCommandError::Usage(usage()))?;
61 match arg.as_str() {
62 "--manifest" => {
63 manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
64 }
65 "--help" | "-h" => return Err(ManifestCommandError::Usage(usage())),
66 _ => return Err(ManifestCommandError::UnknownOption(arg)),
67 }
68 }
69
70 Ok(Self {
71 manifest: manifest.ok_or(ManifestCommandError::MissingOption("--manifest"))?,
72 })
73 }
74}
75
76pub fn run<I>(args: I) -> Result<(), ManifestCommandError>
78where
79 I: IntoIterator<Item = OsString>,
80{
81 let mut args = args.into_iter();
82 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
83 return Err(ManifestCommandError::Usage(usage()));
84 };
85
86 match command.as_str() {
87 "validate" => {
88 let options = ManifestValidateOptions::parse(args)?;
89 let manifest = validate_manifest(&options)?;
90 write_validation_summary(&manifest)?;
91 Ok(())
92 }
93 "help" | "--help" | "-h" => Err(ManifestCommandError::Usage(usage())),
94 _ => Err(ManifestCommandError::UnknownOption(command)),
95 }
96}
97
98pub fn validate_manifest(
100 options: &ManifestValidateOptions,
101) -> Result<FleetBackupManifest, ManifestCommandError> {
102 let data = fs::read_to_string(&options.manifest)?;
103 let manifest: FleetBackupManifest = serde_json::from_str(&data)?;
104 manifest.validate()?;
105 Ok(manifest)
106}
107
108fn write_validation_summary(manifest: &FleetBackupManifest) -> Result<(), ManifestCommandError> {
110 let stdout = io::stdout();
111 let mut handle = stdout.lock();
112 serde_json::to_writer_pretty(
113 &mut handle,
114 &json!({
115 "status": "valid",
116 "backup_id": manifest.backup_id,
117 "members": manifest.fleet.members.len(),
118 "topology_hash": manifest.fleet.topology_hash,
119 }),
120 )?;
121 writeln!(handle)?;
122 Ok(())
123}
124
125fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, ManifestCommandError>
127where
128 I: Iterator<Item = OsString>,
129{
130 args.next()
131 .and_then(|value| value.into_string().ok())
132 .ok_or(ManifestCommandError::MissingValue(option))
133}
134
135const fn usage() -> &'static str {
137 "usage: canic manifest validate --manifest <file>"
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use canic_backup::manifest::{
144 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember, FleetSection,
145 IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
146 VerificationPlan,
147 };
148 use std::time::{SystemTime, UNIX_EPOCH};
149
150 const ROOT: &str = "aaaaa-aa";
151 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
152
153 #[test]
155 fn parses_manifest_validate_options() {
156 let options = ManifestValidateOptions::parse([
157 OsString::from("--manifest"),
158 OsString::from("manifest.json"),
159 ])
160 .expect("parse options");
161
162 assert_eq!(options.manifest, PathBuf::from("manifest.json"));
163 }
164
165 #[test]
167 fn validate_manifest_reads_and_validates_manifest() {
168 let root = temp_dir("canic-cli-manifest-validate");
169 fs::create_dir_all(&root).expect("create temp root");
170 let manifest_path = root.join("manifest.json");
171
172 fs::write(
173 &manifest_path,
174 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
175 )
176 .expect("write manifest");
177
178 let options = ManifestValidateOptions {
179 manifest: manifest_path,
180 };
181
182 let manifest = validate_manifest(&options).expect("validate manifest");
183
184 fs::remove_dir_all(root).expect("remove temp root");
185 assert_eq!(manifest.backup_id, "backup-test");
186 assert_eq!(manifest.fleet.members.len(), 1);
187 }
188
189 fn valid_manifest() -> FleetBackupManifest {
191 FleetBackupManifest {
192 manifest_version: 1,
193 backup_id: "backup-test".to_string(),
194 created_at: "2026-05-03T00:00:00Z".to_string(),
195 tool: ToolMetadata {
196 name: "canic".to_string(),
197 version: "0.30.1".to_string(),
198 },
199 source: SourceMetadata {
200 environment: "local".to_string(),
201 root_canister: ROOT.to_string(),
202 },
203 consistency: ConsistencySection {
204 mode: ConsistencyMode::CrashConsistent,
205 backup_units: vec![BackupUnit {
206 unit_id: "fleet".to_string(),
207 kind: BackupUnitKind::SubtreeRooted,
208 roles: vec!["root".to_string()],
209 consistency_reason: None,
210 dependency_closure: Vec::new(),
211 topology_validation: "subtree-closed".to_string(),
212 quiescence_strategy: None,
213 }],
214 },
215 fleet: FleetSection {
216 topology_hash_algorithm: "sha256".to_string(),
217 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
218 discovery_topology_hash: HASH.to_string(),
219 pre_snapshot_topology_hash: HASH.to_string(),
220 topology_hash: HASH.to_string(),
221 members: vec![fleet_member()],
222 },
223 verification: VerificationPlan::default(),
224 }
225 }
226
227 fn fleet_member() -> FleetMember {
229 FleetMember {
230 role: "root".to_string(),
231 canister_id: ROOT.to_string(),
232 parent_canister_id: None,
233 subnet_canister_id: Some(ROOT.to_string()),
234 controller_hint: None,
235 identity_mode: IdentityMode::Fixed,
236 restore_group: 1,
237 verification_class: "basic".to_string(),
238 verification_checks: vec![VerificationCheck {
239 kind: "status".to_string(),
240 method: None,
241 roles: vec!["root".to_string()],
242 }],
243 source_snapshot: SourceSnapshot {
244 snapshot_id: "root-snapshot".to_string(),
245 module_hash: None,
246 wasm_hash: None,
247 code_version: Some("v0.30.1".to_string()),
248 artifact_path: "artifacts/root".to_string(),
249 checksum_algorithm: "sha256".to_string(),
250 },
251 }
252 }
253
254 fn temp_dir(prefix: &str) -> PathBuf {
256 let nanos = SystemTime::now()
257 .duration_since(UNIX_EPOCH)
258 .expect("system time after epoch")
259 .as_nanos();
260 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
261 }
262}