Skip to main content

canic_cli/manifest/
mod.rs

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///
13/// ManifestCommandError
14///
15
16#[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///
38/// ManifestValidateOptions
39///
40
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct ManifestValidateOptions {
43    pub manifest: PathBuf,
44    pub out: Option<PathBuf>,
45}
46
47impl ManifestValidateOptions {
48    /// Parse manifest validation options from CLI arguments.
49    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
64// Build the manifest validation parser.
65fn 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
72/// Run a manifest subcommand.
73pub 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
101/// Read and validate a fleet backup manifest from disk.
102pub 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
111// Write a concise validation summary for shell use.
112fn 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
121// Return manifest command usage text.
122const 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    // Ensure manifest validation options parse the intended command shape.
139    #[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    // Ensure manifest validation loads JSON and runs the manifest contract.
154    #[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    // Ensure manifest validation summaries can be written for automation.
179    #[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    // Build one valid manifest for validation tests.
206    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    // Build one valid manifest member.
239    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    // Build a unique temporary directory.
264    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}