From 370ae05620493a3c6b14473a9e1784934904679c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Saparelli?= Date: Wed, 16 Feb 2022 14:49:07 +1300 Subject: [PATCH] QuickInstall support (#94) See this issue: https://github.com/alsuren/cargo-quickinstall/issues/27 Quick Install is a hosted repo of built crates, essentially. The approach I've taken here is a list of strategies: 1. First, we check the crate meta or default and build the URL to the repo. Once we have that, we perform a `HEAD` request to the URL to see if it's available. 2. If it's not, we build the URL to the quickinstall repo, and perform a `HEAD` to there. As soon as we've got a hit, we use that. I've built it so it's extensible with more strategies. This could be useful for #4. This also adds a prompt before downloading from third-party sources, and logs a short name for a source, which is easier to glance than a full URL, and includes a quick refactor of the install/link machinery. --- Cargo.lock | 13 +++ Cargo.toml | 2 + README.md | 8 +- build.rs | 1 - src/bins.rs | 125 +++++++++++++++++++++ src/drivers.rs | 95 +++++++++------- src/fetchers.rs | 63 +++++++++++ src/fetchers/gh_crate_meta.rs | 74 +++++++++++++ src/fetchers/quickinstall.rs | 80 ++++++++++++++ src/helpers.rs | 89 +++++++++++---- src/lib.rs | 62 +++-------- src/main.rs | 197 ++++++++++++++++------------------ 12 files changed, 600 insertions(+), 209 deletions(-) create mode 100644 src/bins.rs create mode 100644 src/fetchers.rs create mode 100644 src/fetchers/gh_crate_meta.rs create mode 100644 src/fetchers/quickinstall.rs diff --git a/Cargo.lock b/Cargo.lock index c3c5a710..d98280ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,17 @@ version = "1.0.53" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94a45b455c14666b85fc40a019e8ab9eb75e3a124e05494f5397122bc9eb06e0" +[[package]] +name = "async-trait" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061a7acccaa286c011ddc30970520b98fa40e00c9d644633fb26b5fc63a265e3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atty" version = "0.2.14" @@ -114,6 +125,7 @@ name = "cargo-binstall" version = "0.5.0" dependencies = [ "anyhow", + "async-trait", "cargo_metadata", "cargo_toml", "crates-index", @@ -133,6 +145,7 @@ dependencies = [ "tempdir", "tinytemplate", "tokio", + "url", "xz2", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index 2e5bd883..6a4e3d5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,8 @@ crates-index = "0.18.5" semver = "1.0.5" xz2 = "0.1.6" zip = "0.5.13" +async-trait = "0.1.52" +url = "2.2.2" [dev-dependencies] env_logger = "0.9.0" diff --git a/README.md b/README.md index f2464ea6..7c804a92 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ yes - [x] Fetch crate / manifest via crates.io - [ ] Fetch crate / manifest via git (/ github / gitlab) - [x] Use local crate / manifest (`--manifest-path`) + - [x] Fetch build from the [quickinstall](https://github.com/alsuren/cargo-quickinstall) repository - [ ] Unofficial packaging - Package formats - [x] Tgz @@ -126,6 +127,10 @@ By default `binstall` is setup to work with github releases, and expects to find If your package already uses this approach, you shouldn't need to set anything. +### QuickInstall + +[QuickInstall](https://github.com/alsuren/cargo-quickinstall) is an unofficial repository of prebuilt binaries for Crates, and `binstall` has built-in support for it! If your crate is built by QuickInstall, it will already work with `binstall`. However, binaries as configured above take precedence when they exist. + ### Examples For example, the default configuration (as shown above) for a crate called `radio-sx128x` (version: `v0.14.1-alpha.5` on x86_64 linux) would be interpolated to: @@ -166,9 +171,6 @@ Which provides a binary path of: `sx128x-util-x86_64-unknown-linux-gnu[.exe]`. I - Why use the cargo manifest? - Crates already have these, and they already contain a significant portion of the required information. Also there's this great and woefully underused (imo) `[package.metadata]` field. -- Why not use a binary repository instead? - - Then we'd need to _host_ a binary repository, and worry about publishing and all the other fun things that come with releasing software. - This way we can use existing CI infrastructure and build artifacts, and maintainers can choose how to distribute their packages. - Is this secure? - Yes and also no? We're not (yet? #1) doing anything to verify the CI binaries are produced by the right person / organisation. However, we're pulling data from crates.io and the cargo manifest, both of which are _already_ trusted entities, and this is diff --git a/build.rs b/build.rs index a77a878b..a6f29869 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,3 @@ - // Fetch build target and define this for the compiler fn main() { println!( diff --git a/src/bins.rs b/src/bins.rs new file mode 100644 index 00000000..d443e9a3 --- /dev/null +++ b/src/bins.rs @@ -0,0 +1,125 @@ +use std::path::PathBuf; + +use cargo_toml::Product; +use serde::Serialize; + +use crate::{PkgFmt, PkgMeta, Template}; + +pub struct BinFile { + pub base_name: String, + pub source: PathBuf, + pub dest: PathBuf, + pub link: PathBuf, +} + +impl BinFile { + pub fn from_product(data: &Data, product: &Product) -> Result { + let base_name = product.name.clone().unwrap(); + + // Generate binary path via interpolation + let ctx = Context { + name: &data.name, + repo: data.repo.as_ref().map(|s| &s[..]), + target: &data.target, + version: &data.version, + format: if data.target.contains("windows") { + ".exe" + } else { + "" + }, + bin: &base_name, + }; + + // Generate install paths + // Source path is the download dir + the generated binary path + let source_file_path = ctx.render(&data.meta.bin_dir)?; + let source = if data.meta.pkg_fmt == PkgFmt::Bin { + data.bin_path.clone() + } else { + data.bin_path.join(&source_file_path) + }; + + // Destination path is the install dir + base-name-version{.format} + let dest_file_path = ctx.render("{ bin }-v{ version }{ format }")?; + let dest = data.install_path.join(dest_file_path); + + // Link at install dir + base name + let link = data.install_path.join(&base_name); + + Ok(Self { + base_name, + source, + dest, + link, + }) + } + + pub fn preview_bin(&self) -> String { + format!( + "{} ({} -> {})", + self.base_name, + self.source.file_name().unwrap().to_string_lossy(), + self.dest.display() + ) + } + + pub fn preview_link(&self) -> String { + format!( + "{} ({} -> {})", + self.base_name, + self.dest.display(), + self.link.display() + ) + } + + pub fn install_bin(&self) -> Result<(), anyhow::Error> { + // TODO: check if file already exists + std::fs::copy(&self.source, &self.dest)?; + + #[cfg(target_family = "unix")] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&self.dest, std::fs::Permissions::from_mode(0o755))?; + } + + Ok(()) + } + + pub fn install_link(&self) -> Result<(), anyhow::Error> { + // Remove existing symlink + // TODO: check if existing symlink is correct + if self.link.exists() { + std::fs::remove_file(&self.link)?; + } + + #[cfg(target_family = "unix")] + std::os::unix::fs::symlink(&self.dest, &self.link)?; + #[cfg(target_family = "windows")] + std::os::windows::fs::symlink_file(&self.dest, &self.link)?; + + Ok(()) + } +} + +/// Data required to get bin paths +pub struct Data { + pub name: String, + pub target: String, + pub version: String, + pub repo: Option, + pub meta: PkgMeta, + pub bin_path: PathBuf, + pub install_path: PathBuf, +} + +#[derive(Clone, Debug, Serialize)] +struct Context<'c> { + pub name: &'c str, + pub repo: Option<&'c str>, + pub target: &'c str, + pub version: &'c str, + pub format: &'c str, + pub bin: &'c str, +} + +impl<'c> Template for Context<'c> {} diff --git a/src/drivers.rs b/src/drivers.rs index f10b1448..a1f55bd2 100644 --- a/src/drivers.rs +++ b/src/drivers.rs @@ -1,39 +1,43 @@ - -use std::time::Duration; use std::path::{Path, PathBuf}; +use std::time::Duration; -use log::{debug}; -use anyhow::{Context, anyhow}; +use anyhow::{anyhow, Context}; +use log::debug; use semver::{Version, VersionReq}; use crates_io_api::AsyncClient; -use crate::PkgFmt; use crate::helpers::*; +use crate::PkgFmt; -fn find_version<'a, V: Iterator>(requirement: &str, version_iter: V) -> Result { +fn find_version<'a, V: Iterator>( + requirement: &str, + version_iter: V, +) -> Result { // Parse version requirement let version_req = VersionReq::parse(requirement)?; // Filter for matching versions - let mut filtered: Vec<_> = version_iter.filter(|v| { - // Remove leading `v` for git tags - let ver_str = match v.strip_prefix("s") { - Some(v) => v, - None => v, - }; + let mut filtered: Vec<_> = version_iter + .filter(|v| { + // Remove leading `v` for git tags + let ver_str = match v.strip_prefix("s") { + Some(v) => v, + None => v, + }; - // Parse out version - let ver = match Version::parse(ver_str) { - Ok(sv) => sv, - Err(_) => return false, - }; + // Parse out version + let ver = match Version::parse(ver_str) { + Ok(sv) => sv, + Err(_) => return false, + }; - debug!("Version: {:?}", ver); + debug!("Version: {:?}", ver); - // Filter by version match - version_req.matches(&ver) - }).collect(); + // Filter by version match + version_req.matches(&ver) + }) + .collect(); // Sort by highest matching version filtered.sort_by(|a, b| { @@ -48,13 +52,19 @@ fn find_version<'a, V: Iterator>(requirement: &str, version_iter: // Return highest version match filtered.get(0) { Some(v) => Ok(v.to_string()), - None => Err(anyhow!("No matching version for requirement: '{}'", version_req)) + None => Err(anyhow!( + "No matching version for requirement: '{}'", + version_req + )), } } /// Fetch a crate by name and version from crates.io -pub async fn fetch_crate_cratesio(name: &str, version_req: &str, temp_dir: &Path) -> Result { - +pub async fn fetch_crate_cratesio( + name: &str, + version_req: &str, + temp_dir: &Path, +) -> Result { // Fetch / update index debug!("Updating crates.io index"); let mut index = crates_index::Index::new_cargo_default()?; @@ -65,37 +75,48 @@ pub async fn fetch_crate_cratesio(name: &str, version_req: &str, temp_dir: &Path let base_info = match index.crate_(name) { Some(i) => i, None => { - return Err(anyhow::anyhow!("Error fetching information for crate {}", name)); + return Err(anyhow::anyhow!( + "Error fetching information for crate {}", + name + )); } }; // Locate matching version - let version_iter = base_info.versions().iter().map(|v| v.version() ); + let version_iter = base_info.versions().iter().map(|v| v.version()); let version_name = find_version(version_req, version_iter)?; - + // Build crates.io api client - let api_client = AsyncClient::new("cargo-binstall (https://github.com/ryankurte/cargo-binstall)", Duration::from_millis(100))?; + let api_client = AsyncClient::new( + "cargo-binstall (https://github.com/ryankurte/cargo-binstall)", + Duration::from_millis(100), + )?; // Fetch online crate information - let crate_info = api_client.get_crate(name.as_ref()).await + let crate_info = api_client + .get_crate(name.as_ref()) + .await .context("Error fetching crate information")?; // Fetch information for the filtered version let version = match crate_info.versions.iter().find(|v| v.num == version_name) { Some(v) => v, None => { - return Err(anyhow::anyhow!("No information found for crate: '{}' version: '{}'", - name, version_name)); + return Err(anyhow::anyhow!( + "No information found for crate: '{}' version: '{}'", + name, + version_name + )); } }; debug!("Found information for crate version: '{}'", version.num); - + // Download crate to temporary dir (crates.io or git?) let crate_url = format!("https://crates.io/{}", version.dl_path); let tgz_path = temp_dir.join(format!("{}.tgz", name)); - debug!("Fetching crate from: {}", crate_url); + debug!("Fetching crate from: {}", crate_url); // Download crate download(&crate_url, &tgz_path).await?; @@ -111,8 +132,10 @@ pub async fn fetch_crate_cratesio(name: &str, version_req: &str, temp_dir: &Path /// Fetch a crate by name and version from github /// TODO: implement this -pub async fn fetch_crate_gh_releases(_name: &str, _version: Option<&str>, _temp_dir: &Path) -> Result { - +pub async fn fetch_crate_gh_releases( + _name: &str, + _version: Option<&str>, + _temp_dir: &Path, +) -> Result { unimplemented!(); } - diff --git a/src/fetchers.rs b/src/fetchers.rs new file mode 100644 index 00000000..eedd920b --- /dev/null +++ b/src/fetchers.rs @@ -0,0 +1,63 @@ +use std::path::Path; + +pub use gh_crate_meta::*; +pub use quickinstall::*; + +use crate::{PkgFmt, PkgMeta}; + +mod gh_crate_meta; +mod quickinstall; + +#[async_trait::async_trait] +pub trait Fetcher { + /// Create a new fetcher from some data + async fn new(data: &Data) -> Result, anyhow::Error> + where + Self: Sized; + + /// Fetch a package + async fn fetch(&self, dst: &Path) -> Result<(), anyhow::Error>; + + /// Check if a package is available for download + async fn check(&self) -> Result; + + /// Return the package format + fn pkg_fmt(&self) -> PkgFmt; + + /// A short human-readable name or descriptor for the package source + fn source_name(&self) -> String; + + /// Should return true if the remote is from a third-party source + fn is_third_party(&self) -> bool; +} + +/// Data required to fetch a package +#[derive(Debug)] +pub struct Data { + pub name: String, + pub target: String, + pub version: String, + pub repo: Option, + pub meta: PkgMeta, +} + +#[derive(Default)] +pub struct MultiFetcher { + fetchers: Vec>, +} + +impl MultiFetcher { + pub fn add(&mut self, fetcher: Box) { + self.fetchers.push(fetcher); + } + + pub async fn first_available(&self) -> Option<&dyn Fetcher> { + for fetcher in &self.fetchers { + if fetcher.check().await.unwrap_or(false) { + return Some(&**fetcher); + } + } + + None + } +} diff --git a/src/fetchers/gh_crate_meta.rs b/src/fetchers/gh_crate_meta.rs new file mode 100644 index 00000000..8ee3ad0f --- /dev/null +++ b/src/fetchers/gh_crate_meta.rs @@ -0,0 +1,74 @@ +use std::path::Path; + +use log::{debug, info}; +use reqwest::Method; +use serde::Serialize; +use url::Url; + +use super::Data; +use crate::{download, remote_exists, PkgFmt, Template}; + +pub struct GhCrateMeta { + url: Url, + pkg_fmt: PkgFmt, +} + +#[async_trait::async_trait] +impl super::Fetcher for GhCrateMeta { + async fn new(data: &Data) -> Result, anyhow::Error> { + // Generate context for URL interpolation + let ctx = Context { + name: &data.name, + repo: data.repo.as_ref().map(|s| &s[..]), + target: &data.target, + version: &data.version, + format: data.meta.pkg_fmt.to_string(), + }; + debug!("Using context: {:?}", ctx); + + Ok(Box::new(Self { + url: Url::parse(&ctx.render(&data.meta.pkg_url)?)?, + pkg_fmt: data.meta.pkg_fmt, + })) + } + + async fn check(&self) -> Result { + info!("Checking for package at: '{}'", self.url); + remote_exists(self.url.as_str(), Method::HEAD).await + } + + async fn fetch(&self, dst: &Path) -> Result<(), anyhow::Error> { + info!("Downloading package from: '{}'", self.url); + download(self.url.as_str(), dst).await + } + + fn pkg_fmt(&self) -> PkgFmt { + self.pkg_fmt + } + + fn source_name(&self) -> String { + if let Some(domain) = self.url.domain() { + domain.to_string() + } else if let Some(host) = self.url.host_str() { + host.to_string() + } else { + self.url.to_string() + } + } + + fn is_third_party(&self) -> bool { + false + } +} + +/// Template for constructing download paths +#[derive(Clone, Debug, Serialize)] +struct Context<'c> { + pub name: &'c str, + pub repo: Option<&'c str>, + pub target: &'c str, + pub version: &'c str, + pub format: String, +} + +impl<'c> Template for Context<'c> {} diff --git a/src/fetchers/quickinstall.rs b/src/fetchers/quickinstall.rs new file mode 100644 index 00000000..cbea5f9d --- /dev/null +++ b/src/fetchers/quickinstall.rs @@ -0,0 +1,80 @@ +use std::path::Path; + +use log::info; +use reqwest::Method; + +use super::Data; +use crate::{download, remote_exists, PkgFmt}; + +const BASE_URL: &str = "https://github.com/alsuren/cargo-quickinstall/releases/download"; +const STATS_URL: &str = "https://warehouse-clerk-tmp.vercel.app/api/crate"; +const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); + +pub struct QuickInstall { + package: String, +} + +#[async_trait::async_trait] +impl super::Fetcher for QuickInstall { + async fn new(data: &Data) -> Result, anyhow::Error> { + let crate_name = &data.name; + let version = &data.version; + let target = &data.target; + Ok(Box::new(Self { + package: format!("{crate_name}-{version}-{target}"), + })) + } + + async fn check(&self) -> Result { + let url = self.package_url(); + self.report().await?; + info!("Checking for package at: '{url}'"); + remote_exists(&url, Method::HEAD).await + } + + async fn fetch(&self, dst: &Path) -> Result<(), anyhow::Error> { + let url = self.package_url(); + info!("Downloading package from: '{url}'"); + download(&url, dst).await + } + + fn pkg_fmt(&self) -> PkgFmt { + PkgFmt::Tgz + } + + fn source_name(&self) -> String { + String::from("QuickInstall") + } + fn is_third_party(&self) -> bool { + true + } +} + +impl QuickInstall { + fn package_url(&self) -> String { + format!( + "{base_url}/{package}/{package}.tar.gz", + base_url = BASE_URL, + package = self.package + ) + } + + fn stats_url(&self) -> String { + format!( + "{stats_url}/{package}.tar.gz", + stats_url = STATS_URL, + package = self.package + ) + } + + pub async fn report(&self) -> Result<(), anyhow::Error> { + info!("Sending installation report to quickinstall (anonymous)"); + reqwest::Client::builder() + .user_agent(USER_AGENT) + .build()? + .request(Method::HEAD, &self.stats_url()) + .send() + .await?; + Ok(()) + } +} diff --git a/src/helpers.rs b/src/helpers.rs index dce5c9f3..a94437a6 100644 --- a/src/helpers.rs +++ b/src/helpers.rs @@ -1,20 +1,23 @@ - use std::path::{Path, PathBuf}; -use log::{debug, info, error}; +use log::{debug, error, info}; -use cargo_toml::{Manifest}; +use cargo_toml::Manifest; use flate2::read::GzDecoder; +use serde::Serialize; use tar::Archive; +use tinytemplate::TinyTemplate; use xz2::read::XzDecoder; use zip::read::ZipArchive; -use crate::{Meta}; +use crate::Meta; use super::PkgFmt; /// Load binstall metadata from the crate `Cargo.toml` at the provided path -pub fn load_manifest_path>(manifest_path: P) -> Result, anyhow::Error> { +pub fn load_manifest_path>( + manifest_path: P, +) -> Result, anyhow::Error> { debug!("Reading manifest: {}", manifest_path.as_ref().display()); // Load and parse manifest (this checks file system for binary output names) @@ -24,9 +27,13 @@ pub fn load_manifest_path>(manifest_path: P) -> Result Result { + let req = reqwest::Client::new().request(method, url).send().await?; + Ok(req.status().is_success()) +} + /// Download a file from the provided URL to the provided path pub async fn download>(url: &str, path: P) -> Result<(), anyhow::Error> { - debug!("Downloading from: '{}'", url); let resp = reqwest::get(url).await?; @@ -46,51 +53,75 @@ pub async fn download>(url: &str, path: P) -> Result<(), anyhow:: } /// Extract files from the specified source onto the specified path -pub fn extract, P: AsRef>(source: S, fmt: PkgFmt, path: P) -> Result<(), anyhow::Error> { +pub fn extract, P: AsRef>( + source: S, + fmt: PkgFmt, + path: P, +) -> Result<(), anyhow::Error> { match fmt { PkgFmt::Tar => { // Extract to install dir - debug!("Extracting from tar archive '{:?}' to `{:?}`", source.as_ref(), path.as_ref()); + debug!( + "Extracting from tar archive '{:?}' to `{:?}`", + source.as_ref(), + path.as_ref() + ); let dat = std::fs::File::open(source)?; let mut tar = Archive::new(dat); tar.unpack(path)?; - }, + } PkgFmt::Tgz => { // Extract to install dir - debug!("Decompressing from tgz archive '{:?}' to `{:?}`", source.as_ref(), path.as_ref()); + debug!( + "Decompressing from tgz archive '{:?}' to `{:?}`", + source.as_ref(), + path.as_ref() + ); let dat = std::fs::File::open(source)?; let tar = GzDecoder::new(dat); let mut tgz = Archive::new(tar); tgz.unpack(path)?; - }, + } PkgFmt::Txz => { // Extract to install dir - debug!("Decompressing from txz archive '{:?}' to `{:?}`", source.as_ref(), path.as_ref()); + debug!( + "Decompressing from txz archive '{:?}' to `{:?}`", + source.as_ref(), + path.as_ref() + ); let dat = std::fs::File::open(source)?; let tar = XzDecoder::new(dat); let mut txz = Archive::new(tar); txz.unpack(path)?; - }, + } PkgFmt::Zip => { // Extract to install dir - debug!("Decompressing from zip archive '{:?}' to `{:?}`", source.as_ref(), path.as_ref()); + debug!( + "Decompressing from zip archive '{:?}' to `{:?}`", + source.as_ref(), + path.as_ref() + ); let dat = std::fs::File::open(source)?; let mut zip = ZipArchive::new(dat)?; zip.extract(path)?; - }, + } PkgFmt::Bin => { - debug!("Copying binary '{:?}' to `{:?}`", source.as_ref(), path.as_ref()); + debug!( + "Copying binary '{:?}' to `{:?}`", + source.as_ref(), + path.as_ref() + ); // Copy to install dir std::fs::copy(source, path)?; - }, + } }; Ok(()) @@ -101,7 +132,7 @@ pub fn extract, P: AsRef>(source: S, fmt: PkgFmt, path: P) pub fn get_install_path>(install_path: Option

) -> Option { // Command line override first first if let Some(p) = install_path { - return Some(PathBuf::from(p.as_ref())) + return Some(PathBuf::from(p.as_ref())); } // Environmental variables @@ -144,8 +175,24 @@ pub fn confirm() -> Result { match input.as_str().trim() { "yes" => Ok(true), "no" => Ok(false), - _ => { - Err(anyhow::anyhow!("Valid options are 'yes', 'no', please try again")) - } + _ => Err(anyhow::anyhow!( + "Valid options are 'yes', 'no', please try again" + )), + } +} + +pub trait Template: Serialize { + fn render(&self, template: &str) -> Result + where + Self: Sized, + { + // Create template instance + let mut tt = TinyTemplate::new(); + + // Add template to instance + tt.add_template("path", &template)?; + + // Render output + Ok(tt.render("path", self)?) } } diff --git a/src/lib.rs b/src/lib.rs index b8564f38..cf32973b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,7 @@ - use std::collections::HashMap; -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; use strum_macros::{Display, EnumString, EnumVariantNames}; -use tinytemplate::TinyTemplate; - pub mod helpers; pub use helpers::*; @@ -12,20 +9,23 @@ pub use helpers::*; pub mod drivers; pub use drivers::*; +pub mod bins; +pub mod fetchers; /// Compiled target triple, used as default for binary fetching pub const TARGET: &'static str = env!("TARGET"); /// Default package path template (may be overridden in package Cargo.toml) -pub const DEFAULT_PKG_URL: &'static str = "{ repo }/releases/download/v{ version }/{ name }-{ target }-v{ version }.{ format }"; +pub const DEFAULT_PKG_URL: &'static str = + "{ repo }/releases/download/v{ version }/{ name }-{ target }-v{ version }.{ format }"; /// Default binary name template (may be overridden in package Cargo.toml) pub const DEFAULT_BIN_PATH: &'static str = "{ name }-{ target }-v{ version }/{ bin }{ format }"; - /// Binary format enumeration -#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] -#[derive(Display, EnumString, EnumVariantNames)] +#[derive( + Debug, Copy, Clone, PartialEq, Serialize, Deserialize, Display, EnumString, EnumVariantNames, +)] #[strum(serialize_all = "snake_case")] #[serde(rename_all = "snake_case")] pub enum PkgFmt { @@ -131,7 +131,6 @@ impl Default for PkgOverride { } } - #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct BinMeta { @@ -141,36 +140,9 @@ pub struct BinMeta { pub path: String, } -/// Template for constructing download paths -#[derive(Clone, Debug, Serialize)] -pub struct Context { - pub name: String, - pub repo: Option, - pub target: String, - pub version: String, - pub format: String, - pub bin: Option, -} - -impl Context { - /// Render the context into the provided template - pub fn render(&self, template: &str) -> Result { - // Create template instance - let mut tt = TinyTemplate::new(); - - // Add template to instance - tt.add_template("path", &template)?; - - // Render output - let rendered = tt.render("path", self)?; - - Ok(rendered) - } -} - #[cfg(test)] mod test { - use crate::{load_manifest_path}; + use crate::load_manifest_path; use cargo_toml::Product; @@ -187,7 +159,7 @@ mod test { let manifest = load_manifest_path(&manifest_dir).expect("Error parsing metadata"); let package = manifest.package.unwrap(); - let meta = package.metadata.map(|m| m.binstall ).flatten().unwrap(); + let meta = package.metadata.map(|m| m.binstall).flatten().unwrap(); assert_eq!(&package.name, "cargo-binstall"); @@ -198,14 +170,12 @@ mod test { assert_eq!( manifest.bin.as_slice(), - &[ - Product{ - name: Some("cargo-binstall".to_string()), - path: Some("src/main.rs".to_string()), - edition: Some(cargo_toml::Edition::E2018), - ..Default::default() - }, - ], + &[Product { + name: Some("cargo-binstall".to_string()), + path: Some("src/main.rs".to_string()), + edition: Some(cargo_toml::Edition::E2018), + ..Default::default() + },], ); } } diff --git a/src/main.rs b/src/main.rs index 6f7cf72e..9aed9e0c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,17 @@ -use std::path::{PathBuf}; +use std::path::PathBuf; -use log::{debug, info, warn, error, LevelFilter}; -use simplelog::{TermLogger, ConfigBuilder, TerminalMode, ColorChoice}; +use log::{debug, error, info, warn, LevelFilter}; +use simplelog::{ColorChoice, ConfigBuilder, TermLogger, TerminalMode}; use structopt::StructOpt; use tempdir::TempDir; -use cargo_binstall::*; - +use cargo_binstall::{ + bins, + fetchers::{Data, Fetcher, GhCrateMeta, MultiFetcher, QuickInstall}, + *, +}; #[derive(Debug, StructOpt)] struct Options { @@ -48,7 +51,7 @@ struct Options { /// Override manifest source. /// This skips searching crates.io for a manifest and uses - /// the specified path directly, useful for debugging and + /// the specified path directly, useful for debugging and /// when adding `binstall` support. #[structopt(long)] manifest_path: Option, @@ -58,10 +61,8 @@ struct Options { log_level: LevelFilter, } - #[tokio::main] async fn main() -> Result<(), anyhow::Error> { - // Filter extraneous arg when invoked by cargo // `cargo run -- --help` gives ["target/debug/cargo-binstall", "--help"] // `cargo binstall --help` gives ["/home/ryan/.cargo/bin/cargo-binstall", "binstall", "--help"] @@ -78,7 +79,13 @@ async fn main() -> Result<(), anyhow::Error> { log_config.add_filter_ignore("hyper".to_string()); log_config.add_filter_ignore("reqwest".to_string()); log_config.set_location_level(LevelFilter::Off); - TermLogger::init(opts.log_level, log_config.build(), TerminalMode::Mixed, ColorChoice::Auto).unwrap(); + TermLogger::init( + opts.log_level, + log_config.build(), + TerminalMode::Mixed, + ColorChoice::Auto, + ) + .unwrap(); // Create a temporary directory for downloads etc. let temp_dir = TempDir::new("cargo-binstall")?; @@ -92,13 +99,17 @@ async fn main() -> Result<(), anyhow::Error> { Some(p) => p, None => fetch_crate_cratesio(&opts.name, &opts.version, temp_dir.path()).await?, }; - + debug!("Reading manifest: {}", manifest_path.display()); let manifest = load_manifest_path(manifest_path.join("Cargo.toml"))?; let package = manifest.package.unwrap(); let (mut meta, binaries) = ( - package.metadata.map(|m| m.binstall ).flatten().unwrap_or(PkgMeta::default()), + package + .metadata + .map(|m| m.binstall) + .flatten() + .unwrap_or(PkgMeta::default()), manifest.bin, ); @@ -109,42 +120,61 @@ async fn main() -> Result<(), anyhow::Error> { debug!("Found metadata: {:?}", meta); - // Generate context for URL interpolation - let ctx = Context { - name: opts.name.clone(), - repo: package.repository, - target: opts.target.clone(), - version: package.version.clone(), - format: meta.pkg_fmt.to_string(), - bin: None, - }; - - debug!("Using context: {:?}", ctx); - - // Interpolate version / target / etc. - let rendered = ctx.render(&meta.pkg_url)?; - // Compute install directory - let install_path = match get_install_path(opts.install_path.as_deref()) { - Some(p) => p, - None => { - error!("No viable install path found of specified, try `--install-path`"); - return Err(anyhow::anyhow!("No install path found or specified")); - } - }; - + let install_path = get_install_path(opts.install_path.as_deref()).ok_or_else(|| { + error!("No viable install path found of specified, try `--install-path`"); + anyhow::anyhow!("No install path found or specified") + })?; debug!("Using install path: {}", install_path.display()); - info!("Downloading package from: '{}'", rendered); + // Compute temporary directory for downloads + let pkg_path = temp_dir + .path() + .join(format!("pkg-{}.{}", opts.name, meta.pkg_fmt)); + debug!("Using temporary download path: {}", pkg_path.display()); + + let fetcher_data = Data { + name: package.name.clone(), + target: opts.target.clone(), + version: package.version.clone(), + repo: package.repository.clone(), + meta: meta.clone(), + }; + + // Try github releases, then quickinstall + let mut fetchers = MultiFetcher::default(); + fetchers.add(GhCrateMeta::new(&fetcher_data).await?); + fetchers.add(QuickInstall::new(&fetcher_data).await?); + + let fetcher = fetchers.first_available().await.ok_or_else(|| { + error!("File does not exist remotely, cannot proceed"); + anyhow::anyhow!("No viable remote package found") + })?; + + // Prompt user for third-party source + if fetcher.is_third_party() { + warn!( + "The package will be downloaded from third-party source {}", + fetcher.source_name() + ); + if !opts.no_confirm && !confirm()? { + warn!("Installation cancelled"); + return Ok(()); + } + } else { + info!( + "The package will be downloaded from {}", + fetcher.source_name() + ); + } // Download package - let pkg_path = temp_dir.path().join(format!("pkg-{}.{}", opts.name, meta.pkg_fmt)); - download(&rendered, pkg_path.to_str().unwrap()).await?; + fetcher.fetch(&pkg_path).await?; #[cfg(incomplete)] { // Fetch and check package signature if available - if let Some(pub_key) = meta.as_ref().map(|m| m.pub_key.clone() ).flatten() { + if let Some(pub_key) = meta.as_ref().map(|m| m.pub_key.clone()).flatten() { debug!("Found public key: {}", pub_key); // Generate signature file URL @@ -160,7 +190,6 @@ async fn main() -> Result<(), anyhow::Error> { // TODO: do the signature check unimplemented!() - } else { warn!("No public key found, package signature could not be validated"); } @@ -168,7 +197,7 @@ async fn main() -> Result<(), anyhow::Error> { // Extract files let bin_path = temp_dir.path().join(format!("bin-{}", opts.name)); - extract(&pkg_path, meta.pkg_fmt, &bin_path)?; + extract(&pkg_path, fetcher.pkg_fmt(), &bin_path)?; // Bypass cleanup if disabled if opts.no_cleanup { @@ -177,94 +206,58 @@ async fn main() -> Result<(), anyhow::Error> { if binaries.len() == 0 { error!("No binaries specified (or inferred from file system)"); - return Err(anyhow::anyhow!("No binaries specified (or inferred from file system)")); + return Err(anyhow::anyhow!( + "No binaries specified (or inferred from file system)" + )); } // List files to be installed // based on those found via Cargo.toml - let bin_files = binaries.iter().map(|p| { - // Fetch binary base name - let base_name = p.name.clone().unwrap(); + let bin_data = bins::Data { + name: package.name.clone(), + target: opts.target.clone(), + version: package.version.clone(), + repo: package.repository.clone(), + meta, + bin_path, + install_path, + }; - // Generate binary path via interpolation - let mut bin_ctx = ctx.clone(); - bin_ctx.bin = Some(base_name.clone()); - - // Append .exe to windows binaries - bin_ctx.format = match &opts.target.clone().contains("windows") { - true => ".exe".to_string(), - false => "".to_string(), - }; - - // Generate install paths - // Source path is the download dir + the generated binary path - let source_file_path = bin_ctx.render(&meta.bin_dir)?; - let source = if meta.pkg_fmt == PkgFmt::Bin { - bin_path.clone() - } else { - bin_path.join(&source_file_path) - }; - - // Destination path is the install dir + base-name-version{.format} - let dest_file_path = bin_ctx.render("{ bin }-v{ version }{ format }")?; - let dest = install_path.join(dest_file_path); - - // Link at install dir + base name - let link = install_path.join(&base_name); - - Ok((base_name, source, dest, link)) - }).collect::, anyhow::Error>>()?; + let bin_files = binaries + .iter() + .map(|p| bins::BinFile::from_product(&bin_data, p)) + .collect::, anyhow::Error>>()?; // Prompt user for confirmation info!("This will install the following binaries:"); - for (name, source, dest, _link) in &bin_files { - info!(" - {} ({} -> {})", name, source.file_name().unwrap().to_string_lossy(), dest.display()); + for file in &bin_files { + info!(" - {}", file.preview_bin()); } if !opts.no_symlinks { info!("And create (or update) the following symlinks:"); - for (name, _source, dest, link) in &bin_files { - info!(" - {} ({} -> {})", name, dest.display(), link.display()); + for file in &bin_files { + info!(" - {}", file.preview_link()); } } if !opts.no_confirm && !confirm()? { warn!("Installation cancelled"); - return Ok(()) + return Ok(()); } info!("Installing binaries..."); - - // Install binaries - for (_name, source, dest, _link) in &bin_files { - // TODO: check if file already exists - std::fs::copy(source, dest)?; - - #[cfg(target_family = "unix")] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(dest, std::fs::Permissions::from_mode(0o755))?; - } + for file in &bin_files { + file.install_bin()?; } // Generate symlinks if !opts.no_symlinks { - for (_name, _source, dest, link) in &bin_files { - // Remove existing symlink - // TODO: check if existing symlink is correct - if link.exists() { - std::fs::remove_file(&link)?; - } - - #[cfg(target_family = "unix")] - std::os::unix::fs::symlink(dest, link)?; - #[cfg(target_family = "windows")] - std::os::windows::fs::symlink_file(dest, link)?; + for file in &bin_files { + file.install_link()?; } } - - info!("Installation complete!"); + info!("Installation complete!"); Ok(()) } -