wasmer_cli/commands/package/
download.rs1use std::{env::current_dir, path::PathBuf};
2
3use anyhow::{Context, bail};
4use dialoguer::console::{Emoji, style};
5use indicatif::{ProgressBar, ProgressStyle};
6use tempfile::NamedTempFile;
7use wasmer_config::package::{PackageIdent, PackageSource};
8use wasmer_package::utils::from_disk;
9
10use crate::config::WasmerEnv;
11
12#[derive(clap::Parser, Debug)]
22pub struct PackageDownload {
23 #[clap(flatten)]
24 pub env: WasmerEnv,
25
26 #[clap(long)]
28 validate: bool,
29
30 #[clap(short = 'o', long)]
33 out_path: Option<PathBuf>,
34
35 #[clap(long)]
37 quiet: bool,
38
39 #[clap(short, long)]
45 unpack: bool,
46
47 package: PackageSource,
49}
50
51static CREATING_OUTPUT_DIRECTORY_EMOJI: Emoji<'_, '_> = Emoji("📁 ", "");
52static DOWNLOADING_PACKAGE_EMOJI: Emoji<'_, '_> = Emoji("🌐 ", "");
53static RETRIEVING_PACKAGE_INFORMATION_EMOJI: Emoji<'_, '_> = Emoji("📜 ", "");
54static VALIDATING_PACKAGE_EMOJI: Emoji<'_, '_> = Emoji("🔍 ", "");
55static WRITING_PACKAGE_EMOJI: Emoji<'_, '_> = Emoji("📦 ", "");
56
57impl PackageDownload {
58 pub(crate) fn execute(&self) -> Result<(), anyhow::Error> {
59 let total_steps = if self.validate { 5 } else { 4 };
60 let mut step_num = 1;
61
62 let pb = if self.quiet {
64 ProgressBar::hidden()
65 } else {
66 ProgressBar::new_spinner()
67 };
68
69 pb.set_style(ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})")
70 .unwrap()
71 .progress_chars("#>-"));
72
73 pb.println(format!(
74 "{} {}Creating output directory...",
75 style(format!("[{step_num}/{total_steps}]")).bold().dim(),
76 CREATING_OUTPUT_DIRECTORY_EMOJI,
77 ));
78
79 step_num += 1;
80
81 let out_dir = if let Some(parent) = self.out_path.as_ref().and_then(|p| p.parent()) {
82 match parent.metadata() {
83 Ok(m) => {
84 if !m.is_dir() {
85 bail!(
86 "parent of output file is not a directory: '{}'",
87 parent.display()
88 );
89 }
90 parent.to_owned()
91 }
92 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
93 std::fs::create_dir_all(parent)
94 .context("could not create parent directory of output file")?;
95 parent.to_owned()
96 }
97 Err(err) => return Err(err.into()),
98 }
99 } else {
100 current_dir()?
101 };
102
103 if let Some(parent) = self.out_path.as_ref().and_then(|p| p.parent()) {
104 match parent.metadata() {
105 Ok(m) => {
106 if !m.is_dir() {
107 bail!(
108 "parent of output file is not a directory: '{}'",
109 parent.display()
110 );
111 }
112 }
113 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
114 std::fs::create_dir_all(parent)
115 .context("could not create parent directory of output file")?;
116 }
117 Err(err) => return Err(err.into()),
118 }
119 };
120
121 pb.println(format!(
122 "{} {}Retrieving package information...",
123 style(format!("[{step_num}/{total_steps}]")).bold().dim(),
124 RETRIEVING_PACKAGE_INFORMATION_EMOJI
125 ));
126
127 step_num += 1;
128
129 let (download_url, ident, filename) = match &self.package {
130 PackageSource::Ident(PackageIdent::Named(id)) => {
131 let client = self.env.client_unauthennticated()?;
135
136 let version = id.version_or_default().to_string();
137 let version = if version == "*" {
138 String::from("latest")
139 } else {
140 version.to_string()
141 };
142 let full_name = id.full_name();
143
144 let rt = tokio::runtime::Runtime::new()?;
145 let package = rt
146 .block_on(wasmer_backend_api::query::get_package_version(
147 &client,
148 full_name.clone(),
149 version.clone(),
150 ))?
151 .with_context(|| {
152 format!(
153 "could not retrieve package information for package '{}' from registry '{}'",
154 full_name, client.graphql_endpoint(),
155 )
156 })?;
157
158 let download_url = package
159 .distribution_v3
160 .pirita_download_url
161 .context("registry did not provide a container download URL")?;
162
163 let ident = format!("{}@{}", full_name, package.version);
164 let filename = if let Some(ns) = &package.package.namespace {
165 format!(
166 "{}--{}@{}.webc",
167 ns.clone(),
168 package.package.package_name,
169 package.version
170 )
171 } else {
172 format!("{}@{}.webc", package.package.package_name, package.version)
173 };
174
175 (download_url, ident, filename)
176 }
177 PackageSource::Ident(PackageIdent::Hash(hash)) => {
178 let client = self.env.client_unauthennticated()?;
182
183 let rt = tokio::runtime::Runtime::new()?;
184 let pkg = rt.block_on(wasmer_backend_api::query::get_package_release(&client, &hash.to_string()))?
185 .with_context(|| format!("Package with {hash} does not exist in the registry, or is not accessible"))?;
186
187 let ident = hash.to_string();
188 let filename = format!("{hash}.webc");
189
190 (pkg.webc_url, ident, filename)
191 }
192 PackageSource::Path(p) => bail!("cannot download a package from a local path: '{p}'"),
193 PackageSource::Url(url) => bail!("cannot download a package from a URL: '{url}'"),
194 };
195
196 let builder = {
197 let mut builder = reqwest::blocking::ClientBuilder::new();
198 if let Some(proxy) = self.env.proxy()? {
199 builder = builder.proxy(proxy);
200 }
201 builder
202 };
203 let client = builder.build().context("failed to create reqwest client")?;
204
205 let b = client
206 .get(download_url)
207 .header(http::header::ACCEPT, "application/webc");
208
209 pb.println(format!(
210 "{} {DOWNLOADING_PACKAGE_EMOJI}Downloading package {ident} ...",
211 style(format!("[{step_num}/{total_steps}]")).bold().dim(),
212 ));
213
214 step_num += 1;
215
216 let res = b
217 .send()
218 .context("http request failed")?
219 .error_for_status()
220 .context("http request failed with non-success status code")?;
221
222 let webc_total_size = res
223 .headers()
224 .get(http::header::CONTENT_LENGTH)
225 .and_then(|t| t.to_str().ok())
226 .and_then(|t| t.parse::<u64>().ok())
227 .unwrap_or_default();
228
229 if webc_total_size == 0 {
230 bail!("Package is empty");
231 }
232
233 pb.set_length(webc_total_size);
235
236 let mut tmpfile = NamedTempFile::new_in(&out_dir)?;
237 let accepted_contenttypes = vec![
238 "application/webc",
239 "application/octet-stream",
240 "application/wasm",
241 ];
242 let ty = res
243 .headers()
244 .get(http::header::CONTENT_TYPE)
245 .and_then(|t| t.to_str().ok())
246 .unwrap_or_default();
247 if !(accepted_contenttypes.contains(&ty)) {
248 eprintln!(
249 "Warning: response has invalid content type - expected \
250 one of {accepted_contenttypes:?}, got {ty}",
251 );
252 }
253
254 std::io::copy(&mut pb.wrap_read(res), &mut tmpfile)
255 .context("could not write downloaded data to temporary file")?;
256
257 tmpfile.as_file_mut().sync_all()?;
258
259 if self.validate {
260 if !self.quiet {
261 println!(
262 "{} {VALIDATING_PACKAGE_EMOJI}Validating package...",
263 style(format!("[{step_num}/{total_steps}]")).bold().dim(),
264 );
265 }
266
267 step_num += 1;
268
269 from_disk(tmpfile.path())
270 .context("could not parse downloaded file as a package - invalid download?")?;
271 }
272
273 let out_path = if let Some(out_path) = &self.out_path {
274 out_path.clone()
275 } else {
276 out_dir.join(filename)
277 };
278
279 tmpfile.persist(&out_path).with_context(|| {
280 format!(
281 "could not persist temporary file to '{}'",
282 out_path.display()
283 )
284 })?;
285
286 pb.println(format!(
287 "{} {WRITING_PACKAGE_EMOJI}Package downloaded to '{}'",
288 style(format!("[{step_num}/{total_steps}]")).bold().dim(),
289 out_path.display()
290 ));
291
292 pb.finish();
294
295 if self.unpack {
296 let out_dir = if out_path.extension().is_some() {
297 out_path.with_extension("")
298 } else {
299 out_path.with_extension("unpacked")
300 };
301
302 let unpack_cmd = super::unpack::PackageUnpack {
303 out_dir,
304 overwrite: false,
305 quiet: self.quiet,
306 package_path: out_path,
307 format: super::unpack::Format::Package,
308 };
309 unpack_cmd.execute()?;
310 }
311
312 Ok(())
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
322 fn test_cmd_package_download() {
323 let dir = tempfile::tempdir().unwrap();
324
325 let out_path = dir.path().join("hello.webc");
326
327 let cmd = PackageDownload {
328 env: WasmerEnv::new(
329 crate::config::DEFAULT_WASMER_CACHE_DIR.clone(),
330 crate::config::DEFAULT_WASMER_CACHE_DIR.clone(),
331 None,
332 Some("https://registry.wasmer.io/graphql".to_owned().into()),
333 ),
334 validate: true,
335 out_path: Some(out_path.clone()),
336 package: "wasmer/hello@0.1.0".parse().unwrap(),
337 unpack: true,
338 quiet: true,
339 };
340
341 cmd.execute().unwrap();
342
343 from_disk(out_path).unwrap();
344
345 assert!(dir.path().join("hello/wasmer.toml").is_file());
346 }
347}