Skip to main content

canic_cli/frontend/
mod.rs

1//! Public CLI adapter for frontend bundle export and integrity verification.
2//!
3//! The host owns source validation, generation and durable publication.
4
5use crate::cli::{
6    clap::{flag_arg, parse_matches, required_string, string_option, value_arg},
7    defaults::local_environment,
8    globals::{internal_environment_arg, internal_icp_arg},
9    help::print_nested_help,
10};
11use crate::support::icp_target::IcpTargetOptions;
12use canic_host::{
13    frontend::{
14        FrontendError,
15        model::{FrontendAssetCapacityInput, FrontendUploadedInput},
16        ops, workflow,
17    },
18    icp_config::resolve_current_canic_icp_root,
19};
20use clap::Command;
21use std::{ffi::OsString, path::Path};
22use thiserror::Error;
23
24/// CLI boundary errors retain the host's exact typed diagnostic.
25#[derive(Debug, Error)]
26pub enum FrontendCommandError {
27    #[error(transparent)]
28    Clap(#[from] clap::Error),
29
30    #[error(transparent)]
31    Host(#[from] FrontendError),
32
33    #[error(transparent)]
34    Json(#[from] serde_json::Error),
35
36    #[error(transparent)]
37    Root(#[from] canic_host::icp_config::IcpConfigError),
38}
39
40fn command() -> Command {
41    Command::new("frontend").bin_name("canic frontend")
42        .about("Export and verify exact browser bindings from a terminal Fleet")
43        .arg(internal_environment_arg().global(true))
44        .arg(internal_icp_arg().global(true))
45        .arg(flag_arg("json").long("json").global(true).help("Print structured JSON"))
46        .subcommand_required(true)
47        .subcommand(Command::new("capacity").about("Check external asset native cycles against an explicit upload floor")
48            .arg(value_arg("canister").required(true).value_parser(clap::value_parser!(candid::Principal)))
49            .arg(value_arg("payload").long("payload").required(true))
50            .arg(value_arg("minimum-native-cycles").long("minimum-native-cycles").required(true).value_parser(clap::value_parser!(u128)))
51            .arg(value_arg("maximum-payload-bytes").long("maximum-payload-bytes").required(true).value_parser(clap::value_parser!(u64).range(1..)))
52            .arg(value_arg("maximum-files").long("maximum-files").required(true).value_parser(clap::value_parser!(u32).range(1..))))
53        .subcommand(Command::new("export").about("Write a reviewed environment and binding bundle")
54            .arg(value_arg("fleet").required(true))
55            .arg(value_arg("input").long("input").required(true).help("Selected-environment frontend JSON"))
56            .arg(value_arg("out").long("out").required(true).help("New or identical bundle directory")))
57        .subcommand(Command::new("verify").about("Verify files against an independently retained digest")
58            .arg(value_arg("directory").required(true))
59            .arg(value_arg("sha256").long("sha256").required(true)))
60        .subcommand(Command::new("verify-uploaded").about("Read back uploaded handoff files from an asset canister")
61            .arg(value_arg("directory").required(true))
62            .arg(value_arg("sha256").long("sha256").required(true))
63            .arg(value_arg("canister").long("canister").required(true).value_parser(clap::value_parser!(candid::Principal)))
64            .arg(value_arg("prefix").long("prefix").required(true).help("Exact remote handoff directory, such as /canic"))
65            .after_help("Example:\n  canic --environment ic frontend verify-uploaded browser/canic --sha256 <digest> --canister <principal> --prefix /canic"))
66        .after_help("Examples:\n  canic frontend export demo --input frontend-local.json --out browser/canic\n  canic frontend verify browser/canic --sha256 <digest>\n  canic --environment ic frontend export demo --input frontend-ic.json --out browser/canic --json")
67}
68
69/// Parse the selected environment and delegate to the host handoff owner.
70pub fn run(args: impl IntoIterator<Item = OsString>) -> Result<(), FrontendCommandError> {
71    let args = args.into_iter().collect::<Vec<_>>();
72    if print_nested_help(&args, command()) {
73        return Ok(());
74    }
75    let matches = parse_matches(command(), args)?;
76    let (action, leaf) = matches.subcommand().expect("required frontend subcommand");
77    if action == "capacity" {
78        return capacity(&matches, leaf);
79    }
80    if action == "verify-uploaded" {
81        return verify_uploaded(&matches, leaf);
82    }
83    let manifest = match action {
84        "export" => {
85            let input = ops::read_input(Path::new(&required_string(leaf, "input")))?;
86            let environment =
87                string_option(&matches, "environment").unwrap_or_else(local_environment);
88            if input.environment != environment {
89                return Err(FrontendError::Environment.into());
90            }
91            workflow::prepare_handoff(
92                &resolve_current_canic_icp_root()?,
93                &required_string(leaf, "fleet"),
94                &input,
95                Path::new(&required_string(leaf, "out")),
96            )?
97        }
98        "verify" => ops::verify_bundle(
99            Path::new(&required_string(leaf, "directory")),
100            &required_string(leaf, "sha256"),
101        )?,
102        _ => unreachable!("Clap selects the maintained subcommands"),
103    };
104    if matches.get_flag("json") {
105        println!("{}", serde_json::to_string_pretty(&manifest)?);
106    } else {
107        println!(
108            "Frontend bundle: {} / {}",
109            manifest.environment, manifest.fleet
110        );
111        println!("sha256: {}", manifest.manifest_sha256);
112        println!("role instances: {}", manifest.roles.len());
113    }
114    Ok(())
115}
116
117fn verify_uploaded(
118    matches: &clap::ArgMatches,
119    leaf: &clap::ArgMatches,
120) -> Result<(), FrontendCommandError> {
121    let target = IcpTargetOptions::parse(matches);
122    let root = resolve_current_canic_icp_root()?;
123    let input = FrontendUploadedInput {
124        directory: required_string(leaf, "directory").into(),
125        manifest_sha256: required_string(leaf, "sha256"),
126        environment: target.environment.clone(),
127        network: canic_host::network::resolve_canonical_network_id_from_root(
128            &root,
129            &target.environment,
130        )
131        .map_err(FrontendError::from)?,
132        canister_id: *leaf
133            .get_one::<candid::Principal>("canister")
134            .expect("required Principal"),
135        prefix: required_string(leaf, "prefix"),
136    };
137    let selected_environment = sync_variable("ICP_CLI_ENVIRONMENT")?;
138    let selected_canister = sync_variable("ICP_CLI_CID")?;
139    validate_sync_context(
140        &input.environment,
141        input.canister_id,
142        selected_environment.as_deref(),
143        selected_canister.as_deref(),
144    )?;
145    ops::prepare_uploaded(&input)?;
146    let mut reader = ops::IcpFrontendAssetReader::new(&target.icp_cli(&root), &input)?;
147    let report = workflow::verify_uploaded(&input, &mut reader)?;
148    if matches.get_flag("json") {
149        println!("{}", serde_json::to_string_pretty(&report)?);
150    } else {
151        println!(
152            "Verified {} handoff files on {} ({})",
153            report.files.len(),
154            report.canister_id,
155            report.environment
156        );
157        println!("sha256: {}", report.manifest_sha256);
158    }
159    Ok(())
160}
161
162fn sync_variable(name: &str) -> Result<Option<String>, FrontendError> {
163    match std::env::var(name) {
164        Ok(value) => Ok(Some(value)),
165        Err(std::env::VarError::NotPresent) => Ok(None),
166        Err(std::env::VarError::NotUnicode(_)) => Err(FrontendError::Environment),
167    }
168}
169
170fn validate_sync_context(
171    environment: &str,
172    canister: candid::Principal,
173    selected_environment: Option<&str>,
174    selected_canister: Option<&str>,
175) -> Result<(), FrontendError> {
176    if selected_environment.is_some_and(|selected| selected != environment) {
177        return Err(FrontendError::Environment);
178    }
179    if let Some(selected) = selected_canister {
180        let selected =
181            candid::Principal::from_text(selected).map_err(|_| FrontendError::Principal)?;
182        if selected != canister {
183            return Err(FrontendError::Principal);
184        }
185    }
186    Ok(())
187}
188
189fn capacity(
190    matches: &clap::ArgMatches,
191    leaf: &clap::ArgMatches,
192) -> Result<(), FrontendCommandError> {
193    let target = IcpTargetOptions::parse(matches);
194    let report = ops::asset_capacity(
195        &target.icp_cli(&resolve_current_canic_icp_root()?),
196        &FrontendAssetCapacityInput {
197            environment: target.environment,
198            canister_id: *leaf
199                .get_one::<candid::Principal>("canister")
200                .expect("required Principal"),
201            payload_directory: required_string(leaf, "payload").into(),
202            minimum_native_cycles: *leaf
203                .get_one::<u128>("minimum-native-cycles")
204                .expect("required native floor"),
205            maximum_payload_bytes: *leaf
206                .get_one::<u64>("maximum-payload-bytes")
207                .expect("required byte bound"),
208            maximum_files: *leaf
209                .get_one::<u32>("maximum-files")
210                .expect("required file bound"),
211        },
212    )?;
213    if matches.get_flag("json") {
214        println!("{}", serde_json::to_string_pretty(&report)?);
215    } else {
216        println!(
217            "Asset {}: {} native cycles; required {}",
218            report.canister_id, report.native_cycles, report.minimum_native_cycles
219        );
220        println!(
221            "Payload: {} files, {} bytes, sha256 {}",
222            report.payload.files, report.payload.bytes, report.payload.sha256
223        );
224    }
225    if !report.sufficient {
226        return Err(FrontendError::NativeCapacity {
227            available: report
228                .native_cycles
229                .parse()
230                .map_err(|_| FrontendError::NativeBalance)?,
231            required: report
232                .minimum_native_cycles
233                .parse()
234                .map_err(|_| FrontendError::NativeBalance)?,
235        }
236        .into());
237    }
238    Ok(())
239}
240// -----------------------------------------------------------------------------
241// Tests
242// -----------------------------------------------------------------------------
243#[cfg(test)]
244mod tests {
245    use super::*;
246    #[test]
247    fn sync_context_rejects_another_environment_or_asset() {
248        let canister = candid::Principal::from_slice(&[1]);
249        validate_sync_context("proof", canister, Some("proof"), Some(&canister.to_text())).unwrap();
250        validate_sync_context("proof", canister, None, None).unwrap();
251        assert!(matches!(
252            validate_sync_context("proof", canister, Some("local"), None),
253            Err(FrontendError::Environment)
254        ));
255        assert!(matches!(
256            validate_sync_context("proof", canister, None, Some("2vxsx-fae")),
257            Err(FrontendError::Principal)
258        ));
259        assert!(matches!(
260            validate_sync_context("proof", canister, None, Some("invalid")),
261            Err(FrontendError::Principal)
262        ));
263    }
264    #[test]
265    fn uploaded_verification_requires_independent_digest_and_exact_target() {
266        let command = || super::command();
267        assert!(
268            command()
269                .try_get_matches_from([
270                    "frontend",
271                    "verify-uploaded",
272                    "browser/canic",
273                    "--sha256",
274                    "digest",
275                    "--canister",
276                    "rrkah-fqaaa-aaaaa-aaaaq-cai",
277                    "--prefix",
278                    "/canic"
279                ])
280                .is_ok()
281        );
282        assert!(
283            command()
284                .try_get_matches_from([
285                    "frontend",
286                    "verify-uploaded",
287                    "browser/canic",
288                    "--canister",
289                    "rrkah-fqaaa-aaaaa-aaaaq-cai",
290                    "--prefix",
291                    "/canic"
292                ])
293                .is_err()
294        );
295    }
296}