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.
This commit is contained in:
Félix Saparelli 2022-02-16 14:49:07 +13:00 committed by GitHub
parent e691255650
commit 370ae05620
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 600 additions and 209 deletions

13
Cargo.lock generated
View file

@ -32,6 +32,17 @@ version = "1.0.53"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94a45b455c14666b85fc40a019e8ab9eb75e3a124e05494f5397122bc9eb06e0" 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]] [[package]]
name = "atty" name = "atty"
version = "0.2.14" version = "0.2.14"
@ -114,6 +125,7 @@ name = "cargo-binstall"
version = "0.5.0" version = "0.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait",
"cargo_metadata", "cargo_metadata",
"cargo_toml", "cargo_toml",
"crates-index", "crates-index",
@ -133,6 +145,7 @@ dependencies = [
"tempdir", "tempdir",
"tinytemplate", "tinytemplate",
"tokio", "tokio",
"url",
"xz2", "xz2",
"zip", "zip",
] ]

View file

@ -40,6 +40,8 @@ crates-index = "0.18.5"
semver = "1.0.5" semver = "1.0.5"
xz2 = "0.1.6" xz2 = "0.1.6"
zip = "0.5.13" zip = "0.5.13"
async-trait = "0.1.52"
url = "2.2.2"
[dev-dependencies] [dev-dependencies]
env_logger = "0.9.0" env_logger = "0.9.0"

View file

@ -61,6 +61,7 @@ yes
- [x] Fetch crate / manifest via crates.io - [x] Fetch crate / manifest via crates.io
- [ ] Fetch crate / manifest via git (/ github / gitlab) - [ ] Fetch crate / manifest via git (/ github / gitlab)
- [x] Use local crate / manifest (`--manifest-path`) - [x] Use local crate / manifest (`--manifest-path`)
- [x] Fetch build from the [quickinstall](https://github.com/alsuren/cargo-quickinstall) repository
- [ ] Unofficial packaging - [ ] Unofficial packaging
- Package formats - Package formats
- [x] Tgz - [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. 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 ### 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: 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? - Why use the cargo manifest?
- Crates already have these, and they already contain a significant portion of the required information. - 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. 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? - 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. - 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 However, we're pulling data from crates.io and the cargo manifest, both of which are _already_ trusted entities, and this is

View file

@ -1,4 +1,3 @@
// Fetch build target and define this for the compiler // Fetch build target and define this for the compiler
fn main() { fn main() {
println!( println!(

125
src/bins.rs Normal file
View file

@ -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<Self, anyhow::Error> {
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<String>,
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> {}

View file

@ -1,39 +1,43 @@
use std::time::Duration;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration;
use log::{debug}; use anyhow::{anyhow, Context};
use anyhow::{Context, anyhow}; use log::debug;
use semver::{Version, VersionReq}; use semver::{Version, VersionReq};
use crates_io_api::AsyncClient; use crates_io_api::AsyncClient;
use crate::PkgFmt;
use crate::helpers::*; use crate::helpers::*;
use crate::PkgFmt;
fn find_version<'a, V: Iterator<Item=&'a str>>(requirement: &str, version_iter: V) -> Result<String, anyhow::Error> { fn find_version<'a, V: Iterator<Item = &'a str>>(
requirement: &str,
version_iter: V,
) -> Result<String, anyhow::Error> {
// Parse version requirement // Parse version requirement
let version_req = VersionReq::parse(requirement)?; let version_req = VersionReq::parse(requirement)?;
// Filter for matching versions // Filter for matching versions
let mut filtered: Vec<_> = version_iter.filter(|v| { let mut filtered: Vec<_> = version_iter
// Remove leading `v` for git tags .filter(|v| {
let ver_str = match v.strip_prefix("s") { // Remove leading `v` for git tags
Some(v) => v, let ver_str = match v.strip_prefix("s") {
None => v, Some(v) => v,
}; None => v,
};
// Parse out version // Parse out version
let ver = match Version::parse(ver_str) { let ver = match Version::parse(ver_str) {
Ok(sv) => sv, Ok(sv) => sv,
Err(_) => return false, Err(_) => return false,
}; };
debug!("Version: {:?}", ver); debug!("Version: {:?}", ver);
// Filter by version match // Filter by version match
version_req.matches(&ver) version_req.matches(&ver)
}).collect(); })
.collect();
// Sort by highest matching version // Sort by highest matching version
filtered.sort_by(|a, b| { filtered.sort_by(|a, b| {
@ -48,13 +52,19 @@ fn find_version<'a, V: Iterator<Item=&'a str>>(requirement: &str, version_iter:
// Return highest version // Return highest version
match filtered.get(0) { match filtered.get(0) {
Some(v) => Ok(v.to_string()), 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 /// 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<PathBuf, anyhow::Error> { pub async fn fetch_crate_cratesio(
name: &str,
version_req: &str,
temp_dir: &Path,
) -> Result<PathBuf, anyhow::Error> {
// Fetch / update index // Fetch / update index
debug!("Updating crates.io index"); debug!("Updating crates.io index");
let mut index = crates_index::Index::new_cargo_default()?; 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) { let base_info = match index.crate_(name) {
Some(i) => i, Some(i) => i,
None => { None => {
return Err(anyhow::anyhow!("Error fetching information for crate {}", name)); return Err(anyhow::anyhow!(
"Error fetching information for crate {}",
name
));
} }
}; };
// Locate matching version // 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)?; let version_name = find_version(version_req, version_iter)?;
// Build crates.io api client // 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 // 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")?; .context("Error fetching crate information")?;
// Fetch information for the filtered version // Fetch information for the filtered version
let version = match crate_info.versions.iter().find(|v| v.num == version_name) { let version = match crate_info.versions.iter().find(|v| v.num == version_name) {
Some(v) => v, Some(v) => v,
None => { None => {
return Err(anyhow::anyhow!("No information found for crate: '{}' version: '{}'", return Err(anyhow::anyhow!(
name, version_name)); "No information found for crate: '{}' version: '{}'",
name,
version_name
));
} }
}; };
debug!("Found information for crate version: '{}'", version.num); debug!("Found information for crate version: '{}'", version.num);
// Download crate to temporary dir (crates.io or git?) // Download crate to temporary dir (crates.io or git?)
let crate_url = format!("https://crates.io/{}", version.dl_path); let crate_url = format!("https://crates.io/{}", version.dl_path);
let tgz_path = temp_dir.join(format!("{}.tgz", name)); 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
download(&crate_url, &tgz_path).await?; 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 /// Fetch a crate by name and version from github
/// TODO: implement this /// TODO: implement this
pub async fn fetch_crate_gh_releases(_name: &str, _version: Option<&str>, _temp_dir: &Path) -> Result<PathBuf, anyhow::Error> { pub async fn fetch_crate_gh_releases(
_name: &str,
_version: Option<&str>,
_temp_dir: &Path,
) -> Result<PathBuf, anyhow::Error> {
unimplemented!(); unimplemented!();
} }

63
src/fetchers.rs Normal file
View file

@ -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<Box<Self>, 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<bool, anyhow::Error>;
/// 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<String>,
pub meta: PkgMeta,
}
#[derive(Default)]
pub struct MultiFetcher {
fetchers: Vec<Box<dyn Fetcher>>,
}
impl MultiFetcher {
pub fn add(&mut self, fetcher: Box<dyn Fetcher>) {
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
}
}

View file

@ -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<Box<Self>, 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<bool, anyhow::Error> {
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> {}

View file

@ -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<Box<Self>, 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<bool, anyhow::Error> {
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(())
}
}

View file

@ -1,20 +1,23 @@
use std::path::{Path, PathBuf}; 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 flate2::read::GzDecoder;
use serde::Serialize;
use tar::Archive; use tar::Archive;
use tinytemplate::TinyTemplate;
use xz2::read::XzDecoder; use xz2::read::XzDecoder;
use zip::read::ZipArchive; use zip::read::ZipArchive;
use crate::{Meta}; use crate::Meta;
use super::PkgFmt; use super::PkgFmt;
/// Load binstall metadata from the crate `Cargo.toml` at the provided path /// Load binstall metadata from the crate `Cargo.toml` at the provided path
pub fn load_manifest_path<P: AsRef<Path>>(manifest_path: P) -> Result<Manifest<Meta>, anyhow::Error> { pub fn load_manifest_path<P: AsRef<Path>>(
manifest_path: P,
) -> Result<Manifest<Meta>, anyhow::Error> {
debug!("Reading manifest: {}", manifest_path.as_ref().display()); debug!("Reading manifest: {}", manifest_path.as_ref().display());
// Load and parse manifest (this checks file system for binary output names) // Load and parse manifest (this checks file system for binary output names)
@ -24,9 +27,13 @@ pub fn load_manifest_path<P: AsRef<Path>>(manifest_path: P) -> Result<Manifest<M
Ok(manifest) Ok(manifest)
} }
pub async fn remote_exists(url: &str, method: reqwest::Method) -> Result<bool, anyhow::Error> {
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 /// Download a file from the provided URL to the provided path
pub async fn download<P: AsRef<Path>>(url: &str, path: P) -> Result<(), anyhow::Error> { pub async fn download<P: AsRef<Path>>(url: &str, path: P) -> Result<(), anyhow::Error> {
debug!("Downloading from: '{}'", url); debug!("Downloading from: '{}'", url);
let resp = reqwest::get(url).await?; let resp = reqwest::get(url).await?;
@ -46,51 +53,75 @@ pub async fn download<P: AsRef<Path>>(url: &str, path: P) -> Result<(), anyhow::
} }
/// Extract files from the specified source onto the specified path /// Extract files from the specified source onto the specified path
pub fn extract<S: AsRef<Path>, P: AsRef<Path>>(source: S, fmt: PkgFmt, path: P) -> Result<(), anyhow::Error> { pub fn extract<S: AsRef<Path>, P: AsRef<Path>>(
source: S,
fmt: PkgFmt,
path: P,
) -> Result<(), anyhow::Error> {
match fmt { match fmt {
PkgFmt::Tar => { PkgFmt::Tar => {
// Extract to install dir // 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 dat = std::fs::File::open(source)?;
let mut tar = Archive::new(dat); let mut tar = Archive::new(dat);
tar.unpack(path)?; tar.unpack(path)?;
}, }
PkgFmt::Tgz => { PkgFmt::Tgz => {
// Extract to install dir // 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 dat = std::fs::File::open(source)?;
let tar = GzDecoder::new(dat); let tar = GzDecoder::new(dat);
let mut tgz = Archive::new(tar); let mut tgz = Archive::new(tar);
tgz.unpack(path)?; tgz.unpack(path)?;
}, }
PkgFmt::Txz => { PkgFmt::Txz => {
// Extract to install dir // 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 dat = std::fs::File::open(source)?;
let tar = XzDecoder::new(dat); let tar = XzDecoder::new(dat);
let mut txz = Archive::new(tar); let mut txz = Archive::new(tar);
txz.unpack(path)?; txz.unpack(path)?;
}, }
PkgFmt::Zip => { PkgFmt::Zip => {
// Extract to install dir // 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 dat = std::fs::File::open(source)?;
let mut zip = ZipArchive::new(dat)?; let mut zip = ZipArchive::new(dat)?;
zip.extract(path)?; zip.extract(path)?;
}, }
PkgFmt::Bin => { 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 // Copy to install dir
std::fs::copy(source, path)?; std::fs::copy(source, path)?;
}, }
}; };
Ok(()) Ok(())
@ -101,7 +132,7 @@ pub fn extract<S: AsRef<Path>, P: AsRef<Path>>(source: S, fmt: PkgFmt, path: P)
pub fn get_install_path<P: AsRef<Path>>(install_path: Option<P>) -> Option<PathBuf> { pub fn get_install_path<P: AsRef<Path>>(install_path: Option<P>) -> Option<PathBuf> {
// Command line override first first // Command line override first first
if let Some(p) = install_path { if let Some(p) = install_path {
return Some(PathBuf::from(p.as_ref())) return Some(PathBuf::from(p.as_ref()));
} }
// Environmental variables // Environmental variables
@ -144,8 +175,24 @@ pub fn confirm() -> Result<bool, anyhow::Error> {
match input.as_str().trim() { match input.as_str().trim() {
"yes" => Ok(true), "yes" => Ok(true),
"no" => Ok(false), "no" => Ok(false),
_ => { _ => Err(anyhow::anyhow!(
Err(anyhow::anyhow!("Valid options are 'yes', 'no', please try again")) "Valid options are 'yes', 'no', please try again"
} )),
}
}
pub trait Template: Serialize {
fn render(&self, template: &str) -> Result<String, anyhow::Error>
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)?)
} }
} }

View file

@ -1,10 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use serde::{Serialize, Deserialize}; use serde::{Deserialize, Serialize};
use strum_macros::{Display, EnumString, EnumVariantNames}; use strum_macros::{Display, EnumString, EnumVariantNames};
use tinytemplate::TinyTemplate;
pub mod helpers; pub mod helpers;
pub use helpers::*; pub use helpers::*;
@ -12,20 +9,23 @@ pub use helpers::*;
pub mod drivers; pub mod drivers;
pub use drivers::*; pub use drivers::*;
pub mod bins;
pub mod fetchers;
/// Compiled target triple, used as default for binary fetching /// Compiled target triple, used as default for binary fetching
pub const TARGET: &'static str = env!("TARGET"); pub const TARGET: &'static str = env!("TARGET");
/// Default package path template (may be overridden in package Cargo.toml) /// 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) /// Default binary name template (may be overridden in package Cargo.toml)
pub const DEFAULT_BIN_PATH: &'static str = "{ name }-{ target }-v{ version }/{ bin }{ format }"; pub const DEFAULT_BIN_PATH: &'static str = "{ name }-{ target }-v{ version }/{ bin }{ format }";
/// Binary format enumeration /// Binary format enumeration
#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] #[derive(
#[derive(Display, EnumString, EnumVariantNames)] Debug, Copy, Clone, PartialEq, Serialize, Deserialize, Display, EnumString, EnumVariantNames,
)]
#[strum(serialize_all = "snake_case")] #[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum PkgFmt { pub enum PkgFmt {
@ -131,7 +131,6 @@ impl Default for PkgOverride {
} }
} }
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct BinMeta { pub struct BinMeta {
@ -141,36 +140,9 @@ pub struct BinMeta {
pub path: String, pub path: String,
} }
/// Template for constructing download paths
#[derive(Clone, Debug, Serialize)]
pub struct Context {
pub name: String,
pub repo: Option<String>,
pub target: String,
pub version: String,
pub format: String,
pub bin: Option<String>,
}
impl Context {
/// Render the context into the provided template
pub fn render(&self, template: &str) -> Result<String, anyhow::Error> {
// 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)] #[cfg(test)]
mod test { mod test {
use crate::{load_manifest_path}; use crate::load_manifest_path;
use cargo_toml::Product; use cargo_toml::Product;
@ -187,7 +159,7 @@ mod test {
let manifest = load_manifest_path(&manifest_dir).expect("Error parsing metadata"); let manifest = load_manifest_path(&manifest_dir).expect("Error parsing metadata");
let package = manifest.package.unwrap(); 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"); assert_eq!(&package.name, "cargo-binstall");
@ -198,14 +170,12 @@ mod test {
assert_eq!( assert_eq!(
manifest.bin.as_slice(), manifest.bin.as_slice(),
&[ &[Product {
Product{ name: Some("cargo-binstall".to_string()),
name: Some("cargo-binstall".to_string()), path: Some("src/main.rs".to_string()),
path: Some("src/main.rs".to_string()), edition: Some(cargo_toml::Edition::E2018),
edition: Some(cargo_toml::Edition::E2018), ..Default::default()
..Default::default() },],
},
],
); );
} }
} }

View file

@ -1,14 +1,17 @@
use std::path::{PathBuf}; use std::path::PathBuf;
use log::{debug, info, warn, error, LevelFilter}; use log::{debug, error, info, warn, LevelFilter};
use simplelog::{TermLogger, ConfigBuilder, TerminalMode, ColorChoice}; use simplelog::{ColorChoice, ConfigBuilder, TermLogger, TerminalMode};
use structopt::StructOpt; use structopt::StructOpt;
use tempdir::TempDir; use tempdir::TempDir;
use cargo_binstall::*; use cargo_binstall::{
bins,
fetchers::{Data, Fetcher, GhCrateMeta, MultiFetcher, QuickInstall},
*,
};
#[derive(Debug, StructOpt)] #[derive(Debug, StructOpt)]
struct Options { struct Options {
@ -48,7 +51,7 @@ struct Options {
/// Override manifest source. /// Override manifest source.
/// This skips searching crates.io for a manifest and uses /// 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. /// when adding `binstall` support.
#[structopt(long)] #[structopt(long)]
manifest_path: Option<PathBuf>, manifest_path: Option<PathBuf>,
@ -58,10 +61,8 @@ struct Options {
log_level: LevelFilter, log_level: LevelFilter,
} }
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), anyhow::Error> { async fn main() -> Result<(), anyhow::Error> {
// Filter extraneous arg when invoked by cargo // Filter extraneous arg when invoked by cargo
// `cargo run -- --help` gives ["target/debug/cargo-binstall", "--help"] // `cargo run -- --help` gives ["target/debug/cargo-binstall", "--help"]
// `cargo binstall --help` gives ["/home/ryan/.cargo/bin/cargo-binstall", "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("hyper".to_string());
log_config.add_filter_ignore("reqwest".to_string()); log_config.add_filter_ignore("reqwest".to_string());
log_config.set_location_level(LevelFilter::Off); 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. // Create a temporary directory for downloads etc.
let temp_dir = TempDir::new("cargo-binstall")?; let temp_dir = TempDir::new("cargo-binstall")?;
@ -92,13 +99,17 @@ async fn main() -> Result<(), anyhow::Error> {
Some(p) => p, Some(p) => p,
None => fetch_crate_cratesio(&opts.name, &opts.version, temp_dir.path()).await?, None => fetch_crate_cratesio(&opts.name, &opts.version, temp_dir.path()).await?,
}; };
debug!("Reading manifest: {}", manifest_path.display()); debug!("Reading manifest: {}", manifest_path.display());
let manifest = load_manifest_path(manifest_path.join("Cargo.toml"))?; let manifest = load_manifest_path(manifest_path.join("Cargo.toml"))?;
let package = manifest.package.unwrap(); let package = manifest.package.unwrap();
let (mut meta, binaries) = ( 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, manifest.bin,
); );
@ -109,42 +120,61 @@ async fn main() -> Result<(), anyhow::Error> {
debug!("Found metadata: {:?}", meta); 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 // Compute install directory
let install_path = match get_install_path(opts.install_path.as_deref()) { let install_path = get_install_path(opts.install_path.as_deref()).ok_or_else(|| {
Some(p) => p, error!("No viable install path found of specified, try `--install-path`");
None => { anyhow::anyhow!("No install path found or specified")
error!("No viable install path found of specified, try `--install-path`"); })?;
return Err(anyhow::anyhow!("No install path found or specified"));
}
};
debug!("Using install path: {}", install_path.display()); 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 // Download package
let pkg_path = temp_dir.path().join(format!("pkg-{}.{}", opts.name, meta.pkg_fmt)); fetcher.fetch(&pkg_path).await?;
download(&rendered, pkg_path.to_str().unwrap()).await?;
#[cfg(incomplete)] #[cfg(incomplete)]
{ {
// Fetch and check package signature if available // 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); debug!("Found public key: {}", pub_key);
// Generate signature file URL // Generate signature file URL
@ -160,7 +190,6 @@ async fn main() -> Result<(), anyhow::Error> {
// TODO: do the signature check // TODO: do the signature check
unimplemented!() unimplemented!()
} else { } else {
warn!("No public key found, package signature could not be validated"); warn!("No public key found, package signature could not be validated");
} }
@ -168,7 +197,7 @@ async fn main() -> Result<(), anyhow::Error> {
// Extract files // Extract files
let bin_path = temp_dir.path().join(format!("bin-{}", opts.name)); 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 // Bypass cleanup if disabled
if opts.no_cleanup { if opts.no_cleanup {
@ -177,94 +206,58 @@ async fn main() -> Result<(), anyhow::Error> {
if binaries.len() == 0 { if binaries.len() == 0 {
error!("No binaries specified (or inferred from file system)"); 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 // List files to be installed
// based on those found via Cargo.toml // based on those found via Cargo.toml
let bin_files = binaries.iter().map(|p| { let bin_data = bins::Data {
// Fetch binary base name name: package.name.clone(),
let base_name = p.name.clone().unwrap(); target: opts.target.clone(),
version: package.version.clone(),
repo: package.repository.clone(),
meta,
bin_path,
install_path,
};
// Generate binary path via interpolation let bin_files = binaries
let mut bin_ctx = ctx.clone(); .iter()
bin_ctx.bin = Some(base_name.clone()); .map(|p| bins::BinFile::from_product(&bin_data, p))
.collect::<Result<Vec<_>, anyhow::Error>>()?;
// 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::<Result<Vec<_>, anyhow::Error>>()?;
// Prompt user for confirmation // Prompt user for confirmation
info!("This will install the following binaries:"); info!("This will install the following binaries:");
for (name, source, dest, _link) in &bin_files { for file in &bin_files {
info!(" - {} ({} -> {})", name, source.file_name().unwrap().to_string_lossy(), dest.display()); info!(" - {}", file.preview_bin());
} }
if !opts.no_symlinks { if !opts.no_symlinks {
info!("And create (or update) the following symlinks:"); info!("And create (or update) the following symlinks:");
for (name, _source, dest, link) in &bin_files { for file in &bin_files {
info!(" - {} ({} -> {})", name, dest.display(), link.display()); info!(" - {}", file.preview_link());
} }
} }
if !opts.no_confirm && !confirm()? { if !opts.no_confirm && !confirm()? {
warn!("Installation cancelled"); warn!("Installation cancelled");
return Ok(()) return Ok(());
} }
info!("Installing binaries..."); info!("Installing binaries...");
for file in &bin_files {
// Install binaries file.install_bin()?;
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))?;
}
} }
// Generate symlinks // Generate symlinks
if !opts.no_symlinks { if !opts.no_symlinks {
for (_name, _source, dest, link) in &bin_files { for file in &bin_files {
// Remove existing symlink file.install_link()?;
// 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)?;
} }
} }
info!("Installation complete!");
info!("Installation complete!");
Ok(()) Ok(())
} }