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}
47
48impl ManifestValidateOptions {
49    /// Parse manifest validation options from CLI arguments.
50    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
76/// Run a manifest subcommand.
77pub 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
98/// Read and validate a fleet backup manifest from disk.
99pub 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
108// Write a concise validation summary for shell use.
109fn 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
125// Read the next required option value.
126fn 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
135// Return manifest command usage text.
136const 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    // Ensure manifest validation options parse the intended command shape.
154    #[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    // Ensure manifest validation loads JSON and runs the manifest contract.
166    #[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    // Build one valid manifest for validation tests.
190    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    // Build one valid manifest member.
228    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    // Build a unique temporary directory.
255    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}