1use 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#[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
69pub 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#[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}