Skip to main content

canic_cli/manifest/
mod.rs

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///
14/// ManifestCommandError
15///
16
17#[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///
45/// ManifestValidateOptions
46///
47
48#[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    /// Parse manifest validation options from CLI arguments.
57    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
89/// Run a manifest subcommand.
90pub 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
119/// Read and validate a fleet backup manifest from disk.
120pub 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
129// Write a concise validation summary for shell use.
130fn 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
149// Fail closed when callers require the v1 backup/restore design contract.
150fn 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
168// Read the next required option value.
169fn 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
178// Return manifest command usage text.
179const 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    // Ensure manifest validation options parse the intended command shape.
197    #[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    // Ensure manifest validation loads JSON and runs the manifest contract.
214    #[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    // Ensure manifest validation summaries can be written for automation.
240    #[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    // Ensure manifest validation can fail closed after writing conformance output.
278    #[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    // Build one valid manifest for validation tests.
310    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    // Build one valid manifest member.
348    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    // Build a unique temporary directory.
376    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}