Skip to main content

canic_cli/manifest/
mod.rs

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///
12/// ManifestCommandError
13///
14
15#[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///
40/// ManifestValidateOptions
41///
42
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct ManifestValidateOptions {
45    pub manifest: PathBuf,
46    pub out: Option<PathBuf>,
47}
48
49impl ManifestValidateOptions {
50    /// Parse manifest validation options from CLI arguments.
51    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
80/// Run a manifest subcommand.
81pub 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
102/// Read and validate a fleet backup manifest from disk.
103pub 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
112// Write a concise validation summary for shell use.
113fn 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
137// Read the next required option value.
138fn 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
147// Return manifest command usage text.
148const 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    // Ensure manifest validation options parse the intended command shape.
166    #[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    // Ensure manifest validation loads JSON and runs the manifest contract.
181    #[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    // Ensure manifest validation summaries can be written for automation.
206    #[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    // Build one valid manifest for validation tests.
227    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    // Build one valid manifest member.
265    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                checksum: None,
288            },
289        }
290    }
291
292    // Build a unique temporary directory.
293    fn temp_dir(prefix: &str) -> PathBuf {
294        let nanos = SystemTime::now()
295            .duration_since(UNIX_EPOCH)
296            .expect("system time after epoch")
297            .as_nanos();
298        std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
299    }
300}