wasmer_cli/commands/package/
download.rs

1use 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/// Download a package from the registry.
13///
14/// Examples:
15/// * `wasmer package download wasmer/hello`
16///   Download the `wasmer/hello` package, writing to `./hello@<version>.webc`.
17///
18/// * `wasmer package download --unpack wasmer/hello@0.1.0 -o hello.webc`
19///   Download the `wasmer/hello` package version `0.1.0`, writing to `./hello.webc`,
20///   and unpacking it to `./hello.webc.unpacked/`.
21#[derive(clap::Parser, Debug)]
22pub struct PackageDownload {
23    #[clap(flatten)]
24    pub env: WasmerEnv,
25
26    /// Verify that the downloaded file is a valid package.
27    #[clap(long)]
28    validate: bool,
29
30    /// Path where the package file should be written to.
31    /// If not specified, the data will be written to stdout.
32    #[clap(short = 'o', long)]
33    out_path: Option<PathBuf>,
34
35    /// Run the download command without any output
36    #[clap(long)]
37    quiet: bool,
38
39    /// Unpack the downloaded package.
40    ///
41    /// The unpacked directory will be next to the downloaded file, with a `.unpacked` suffix.
42    ///
43    /// Note: unpacking can also be done manually with the `wasmer package unpack` command.
44    #[clap(short, long)]
45    unpack: bool,
46
47    /// The package to download.
48    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        // Setup the progress bar
63        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                // caveat: client_unauthennticated will use a token if provided, it
132                // just won't fail if none is present. So, _unauthenticated() can actually
133                // produce an authenticated client.
134                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                // caveat: client_unauthennticated will use a token if provided, it
179                // just won't fail if none is present. So, _unauthenticated() can actually
180                // produce an authenticated client.
181                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        // Set the length of the progress bar
234        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        // We're done, so finish the progress bar
293        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    /// Download a package from the dev registry.
321    #[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}