pact_plugin_driver/
download.rs

1//! Module that provides functions for downloading plugin files
2
3use std::{fs, io};
4use std::cmp::min;
5use std::fs::File;
6use std::io::{Read, Write};
7#[cfg(unix)]
8use std::os::unix::fs::PermissionsExt;
9use std::path::PathBuf;
10
11use anyhow::{anyhow, bail, Context};
12use flate2::read::GzDecoder;
13use indicatif::{ProgressBar, ProgressStyle};
14use reqwest::Client;
15use serde_json::Value;
16use sha2::{Digest, Sha256};
17use tracing::{debug, info};
18
19use futures_util::StreamExt;
20
21use crate::plugin_models::PactPluginManifest;
22use crate::utils::os_and_arch;
23use tar::Archive;
24
25pub(crate) async fn fetch_json_from_url(source: &str, http_client: &Client) -> anyhow::Result<Value> {
26  info!(%source, "Fetching root document for source");
27  let response: Value = http_client.get(source)
28    .header("accept", "application/json")
29    .send()
30    .await.context("Fetching root document for source")?
31    .json()
32    .await.context("Parsing root JSON document for source")?;
33  debug!(?response, "Got response");
34  Ok(response)
35}
36
37/// Downloads a JSON file from a GitHub release URL
38pub async fn download_json_from_github(
39  http_client: &Client,
40  base_url: &str,
41  tag: &String,
42  filename: &str
43) -> anyhow::Result<Value> {
44  let url = format!("{}/download/{}/{}", base_url, tag, filename);
45  debug!("Downloading JSON file from {}", url);
46  Ok(http_client.get(url)
47    .send()
48    .await?
49    .json()
50    .await?)
51}
52
53/// Downloads the plugin executable from the given base URL for the current OS and architecture.
54pub async fn download_plugin_executable(
55  manifest: &PactPluginManifest,
56  plugin_dir: &PathBuf,
57  http_client: &Client,
58  base_url: &str,
59  tag: &String,
60  display_progress: bool
61) -> anyhow::Result<PathBuf> {
62  let (os, arch) = os_and_arch()?;
63
64  // Check for a single exec .gz file
65  let ext = if os == "windows" { ".exe" } else { "" };
66  let gz_file = format!("pact-{}-plugin-{}-{}{}.gz", manifest.name, os, arch, ext);
67  let sha_file = format!("pact-{}-plugin-{}-{}{}.gz.sha256", manifest.name, os, arch, ext);
68  if github_file_exists(http_client, base_url, tag, gz_file.as_str()).await? {
69    debug!(file = %gz_file, "Found a GZipped file");
70    let file = download_file_from_github(http_client, base_url, tag, gz_file.as_str(), plugin_dir, display_progress).await?;
71
72    if github_file_exists(http_client, base_url, tag, sha_file.as_str()).await? {
73      let sha_file = download_file_from_github(http_client, base_url, tag, sha_file.as_str(), plugin_dir, display_progress).await?;
74      check_sha(&file, &sha_file)?;
75      fs::remove_file(sha_file)?;
76    }
77
78    let file = gunzip_file(&file, plugin_dir, manifest, ext)?;
79    #[cfg(unix)]
80    {
81      let mut perms = fs::metadata(&file)?.permissions();
82      perms.set_mode(0o775);
83      fs::set_permissions(&file, perms)?;
84    }
85
86    return Ok(file);
87  }
88
89  // Check for an arch specific Zip file
90  let zip_file = format!("pact-{}-plugin-{}-{}.zip", manifest.name, os, arch);
91  let zip_sha_file = format!("pact-{}-plugin-{}-{}.zip.sha256", manifest.name, os, arch);
92  if github_file_exists(http_client, base_url, tag, zip_file.as_str()).await? {
93    return download_zip_file(plugin_dir, http_client, base_url, tag, zip_file, zip_sha_file, display_progress).await;
94  }
95
96  // Check for a Zip file
97  let zip_file = format!("pact-{}-plugin.zip", manifest.name);
98  let zip_sha_file = format!("pact-{}-plugin.zip.sha256", manifest.name);
99  if github_file_exists(http_client, base_url, tag, zip_file.as_str()).await? {
100    return download_zip_file(plugin_dir, http_client, base_url, tag, zip_file, zip_sha_file, display_progress).await;
101  }
102
103  // Check for a tar.gz file
104  let tar_gz_file = format!("pact-{}-plugin.tar.gz", manifest.name);
105  let tar_gz_sha_file = format!("pact-{}-plugin.tar.gz.sha256", manifest.name);
106  if github_file_exists(http_client, base_url, tag, tar_gz_file.as_str()).await? {
107    return download_tar_gz_file(plugin_dir, http_client, base_url, tag, tar_gz_file, tar_gz_sha_file, display_progress).await;
108  }
109
110  // Check for an arch specific tar.gz file
111  let tar_gz_file = format!("pact-{}-plugin-{}-{}.tar.gz", manifest.name, os, arch);
112  let tar_gz_sha_file = format!("pact-{}-plugin-{}-{}.tar.gz.sha256", manifest.name, os, arch);
113  if github_file_exists(http_client, base_url, tag, tar_gz_file.as_str()).await? {
114    return download_tar_gz_file(plugin_dir, http_client, base_url, tag, tar_gz_file, tar_gz_sha_file, display_progress).await;
115  }
116
117  // Check for an arch specific tgz file
118  let tgz_file = format!("pact-{}-plugin-{}-{}.tgz", manifest.name, os, arch);
119  let tgz_sha_file = format!("pact-{}-plugin-{}-{}.tgz.sha256", manifest.name, os, arch);
120  if github_file_exists(http_client, base_url, tag, tgz_file.as_str()).await? {
121    return download_tar_gz_file(plugin_dir, http_client, base_url, tag, tgz_file, tgz_sha_file, display_progress).await;
122  }
123
124  // Check for a tgz file
125  let tgz_file = format!("pact-{}-plugin.tgz", manifest.name);
126  let tgz_sha_file = format!("pact-{}-plugin.tgz.sha256", manifest.name);
127  if github_file_exists(http_client, base_url, tag, tgz_file.as_str()).await? {
128    return download_tar_gz_file(plugin_dir, http_client, base_url, tag, tgz_file, tgz_sha_file, display_progress).await;
129  }
130
131  bail!("Did not find a matching file pattern on GitHub to install")
132}
133
134async fn github_file_exists(http_client: &Client, base_url: &str, tag: &String, filename: &str) -> anyhow::Result<bool> {
135  let url = format!("{}/download/{}/{}", base_url, tag, filename);
136  debug!("Checking existence of file from {}", url);
137  Ok(http_client.head(url)
138    .send()
139    .await?
140    .status().is_success())
141}
142
143/// Downloads a plugin zip file from GitHub and installs it
144pub async fn download_zip_file(
145  plugin_dir: &PathBuf,
146  http_client: &Client,
147  base_url: &str,
148  tag: &String,
149  zip_file: String,
150  zip_sha_file: String,
151  display_progress: bool
152) -> anyhow::Result<PathBuf> {
153  debug!(file = %zip_file, "Found a Zip file");
154  let file = download_file_from_github(http_client, base_url, tag, zip_file.as_str(), plugin_dir, display_progress).await?;
155
156  if github_file_exists(http_client, base_url, tag, zip_sha_file.as_str()).await? {
157    let sha_file = download_file_from_github(http_client, base_url, tag, zip_sha_file.as_str(), plugin_dir, display_progress).await?;
158    check_sha(&file, &sha_file)?;
159    fs::remove_file(sha_file)?;
160  }
161
162  unzip_file(&file, plugin_dir)
163}
164
165/// Downloads a plugin tar gz file from GitHub and installs it
166pub async fn download_tar_gz_file(
167  plugin_dir: &PathBuf,
168  http_client: &Client,
169  base_url: &str,
170  tag: &String,
171  tar_gz_file: String,
172  tar_gz_sha_file: String,
173  display_progress: bool
174) -> anyhow::Result<PathBuf> {
175  debug!(file = %tar_gz_file, "Found a tar gz file");
176  let file = download_file_from_github(http_client, base_url, tag, tar_gz_file.as_str(), plugin_dir, display_progress).await?;
177
178  if github_file_exists(http_client, base_url, tag, tar_gz_sha_file.as_str()).await? {
179    let sha_file = download_file_from_github(http_client, base_url, tag, tar_gz_sha_file.as_str(), plugin_dir, display_progress).await?;
180    check_sha(&file, &sha_file)?;
181    fs::remove_file(sha_file)?;
182  }
183
184  extract_tar_gz(&file, plugin_dir)
185}
186
187fn unzip_file(zip_file: &PathBuf, plugin_dir: &PathBuf) -> anyhow::Result<PathBuf> {
188  let mut archive = zip::ZipArchive::new(File::open(zip_file)?)?;
189
190  for i in 0..archive.len() {
191    let mut file = archive.by_index(i).unwrap();
192    let outpath = match file.enclosed_name() {
193      Some(path) => plugin_dir.join(path),
194      None => continue
195    };
196
197    if (*file.name()).ends_with('/') {
198      debug!("Dir {} extracted to \"{}\"", i, outpath.display());
199      fs::create_dir_all(&outpath)?;
200    } else {
201      debug!("File {} extracted to \"{}\" ({} bytes)", i, outpath.display(), file.size());
202      if let Some(p) = outpath.parent() {
203        if !p.exists() {
204          fs::create_dir_all(&p)?;
205        }
206      }
207      let mut outfile = File::create(&outpath)?;
208      io::copy(&mut file, &mut outfile)?;
209    }
210
211    #[cfg(unix)]
212    {
213      if let Some(mode) = file.unix_mode() {
214        fs::set_permissions(&outpath, fs::Permissions::from_mode(mode))?;
215      }
216    }
217  }
218
219  Ok(plugin_dir.clone())
220}
221
222fn gunzip_file(
223  gz_file: &PathBuf,
224  plugin_dir: &PathBuf,
225  manifest: &PactPluginManifest,
226  ext: &str
227) -> anyhow::Result<PathBuf> {
228  let file = if ext.is_empty() {
229    plugin_dir.join(&manifest.entry_point)
230  } else {
231    plugin_dir.join(&manifest.entry_point)
232      .with_extension(ext.strip_prefix('.').unwrap_or(ext))
233  };
234  let mut f = File::create(file.clone())?;
235  let mut gz = GzDecoder::new(File::open(gz_file)?);
236
237  let bytes = io::copy(&mut gz, &mut f)?;
238  debug!(file = %file.display(), "Wrote {} bytes", bytes);
239  fs::remove_file(gz_file)?;
240
241  Ok(file)
242}
243
244fn extract_tar_gz(tar_gz_file: &PathBuf, plugin_dir: &PathBuf) -> anyhow::Result<PathBuf> {
245  let file = File::open(tar_gz_file)?;
246  let gz_decoder = GzDecoder::new(file);
247  let mut archive = Archive::new(gz_decoder);
248
249  archive.unpack(plugin_dir)?;
250  debug!("Unpacked {:?} plugin", tar_gz_file);
251  fs::remove_file(tar_gz_file)?;
252  Ok(tar_gz_file.clone())
253}
254
255/// Downloads a file from GitHub showing console progress
256pub async fn download_file_from_github(
257  http_client: &Client,
258  base_url: &str,
259  tag: &String,
260  filename: &str,
261  plugin_dir: &PathBuf,
262  display_progress: bool
263) -> anyhow::Result<PathBuf> {
264  let url = format!("{}/download/{}/{}", base_url, tag, filename);
265  debug!("Downloading file from {}", url);
266
267  let res = http_client.get(url.as_str()).send().await?;
268  let total_size = res.content_length()
269    .ok_or(anyhow!("Failed to get content length from '{}'", url))?;
270
271  let pb = ProgressBar::new(total_size);
272  if display_progress {
273    pb.set_style(
274      ProgressStyle::with_template("{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})")
275        .unwrap()
276        .progress_chars("#>-"));
277    pb.set_message(format!("Downloading {}", url));
278  }
279
280  let path = plugin_dir.join(filename);
281  let mut file = File::create(path.clone())?;
282  let mut downloaded: u64 = 0;
283  let mut stream = res.bytes_stream();
284
285  while let Some(item) = stream.next().await {
286    let chunk = item?;
287    file.write_all(&chunk)?;
288    let new = min(downloaded + (chunk.len() as u64), total_size);
289    downloaded = new;
290    if display_progress {
291      pb.set_position(new);
292    }
293  }
294
295  if display_progress {
296    pb.finish_with_message(format!("Downloaded {} to {}", url, path.display()));
297  }
298  debug!(url, downloaded_bytes = downloaded, "File downloaded OK");
299  Ok(path.clone())
300}
301
302/// Validates a file against a SHA file
303pub fn check_sha(file: &PathBuf, sha_file: &PathBuf) -> anyhow::Result<()> {
304  debug!(file = %file.display(), sha_file = %sha_file.display(), "Checking SHA of downloaded file");
305  let sha = fs::read_to_string(sha_file).context("Could not read SHA file")?;
306  let sha = sha.split(' ').next().ok_or(anyhow!("SHA file is not correctly formatted"))?;
307  debug!("Downloaded SHA {}", sha);
308
309  let mut hasher = Sha256::new();
310  let mut f = File::open(file.clone())?;
311  let mut buffer = [0_u8; 256];
312  let mut done = false;
313
314  while !done {
315    let amount = f.read(&mut buffer)?;
316    if amount == 0 {
317      done = true;
318    } else if amount == 256 {
319      hasher.update(&buffer);
320    } else {
321      let b = &buffer[0..amount];
322      hasher.update(b);
323    }
324  }
325
326  let result = hasher.finalize();
327  let calculated = format!("{:x}", result);
328  debug!("Calculated SHA {}", calculated);
329  if calculated == sha {
330    Ok(())
331  } else {
332    Err(anyhow!("Downloaded file {} has a checksum mismatch: {} != {}",
333      file.display(), sha, calculated))
334  }
335}