cargo_quickinstall/
lib.rs

1//! Pre-built binary crate installer.
2//!
3//! Tries to install pre-built binary crates whenever possibles.  Falls back to
4//! `cargo install` otherwise.
5
6use guess_host_triple::guess_host_triple;
7use std::{fs::File, path::Path, process};
8use tempfile::NamedTempFile;
9use tinyjson::JsonValue;
10
11pub mod install_error;
12
13use install_error::*;
14
15mod command_ext;
16pub use command_ext::{ChildWithCommand, CommandExt, CommandFormattable};
17
18mod json_value_ext;
19pub use json_value_ext::{JsonExtError, JsonKey, JsonValueExt};
20
21mod utils;
22pub use utils::{get_cargo_bin_dir, utf8_to_string_lossy};
23
24#[derive(Debug)]
25pub struct CommandFailed {
26    pub command: String,
27    pub stdout: String,
28    pub stderr: String,
29}
30
31#[derive(Clone)]
32pub struct CrateDetails {
33    pub crate_name: String,
34    pub version: String,
35    pub target: String,
36}
37
38/// Return (archive_format, url)
39fn get_binstall_upstream_url(target: &str) -> (&'static str, String) {
40    let archive_format = if target.contains("linux") {
41        "tgz"
42    } else {
43        "zip"
44    };
45    let url = format!("https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-{target}.{archive_format}");
46
47    (archive_format, url)
48}
49
50/// Attempt to download and install cargo-binstall from upstream.
51pub fn download_and_install_binstall_from_upstream(target: &str) -> Result<(), InstallError> {
52    let (archive_format, url) = get_binstall_upstream_url(target);
53
54    if archive_format == "tgz" {
55        untar(curl(&url)?)?;
56
57        Ok(())
58    } else {
59        assert_eq!(archive_format, "zip");
60
61        let (zip_file, zip_file_temp_path) = NamedTempFile::new()?.into_parts();
62
63        curl_file(&url, zip_file)?;
64
65        unzip(&zip_file_temp_path)?;
66
67        Ok(())
68    }
69}
70
71pub fn do_dry_run_download_and_install_binstall_from_upstream(
72    target: &str,
73) -> Result<String, InstallError> {
74    let (archive_format, url) = get_binstall_upstream_url(target);
75
76    curl_head(&url)?;
77
78    let cargo_bin_dir = get_cargo_bin_dir()?;
79
80    if archive_format == "tgz" {
81        Ok(format_curl_and_untar_cmd(&url, &cargo_bin_dir))
82    } else {
83        Ok(format!(
84            "temp=\"$(mktemp)\"\n{curl_cmd} >\"$temp\"\nunzip \"$temp\" -d {extdir}",
85            curl_cmd = prepare_curl_bytes_cmd(&url).formattable(),
86            extdir = cargo_bin_dir.display(),
87        ))
88    }
89}
90
91pub fn unzip(zip_file: &Path) -> Result<(), InstallError> {
92    let bin_dir = get_cargo_bin_dir()?;
93
94    process::Command::new("unzip")
95        .arg(zip_file)
96        .arg("-d")
97        .arg(bin_dir)
98        .output_checked_status()?;
99
100    Ok(())
101}
102
103pub fn get_cargo_binstall_version() -> Option<String> {
104    let output = std::process::Command::new("cargo")
105        .args(["binstall", "-V"])
106        .output()
107        .ok()?;
108    if !output.status.success() {
109        return None;
110    }
111
112    String::from_utf8(output.stdout).ok()
113}
114
115pub fn install_crate_curl(
116    details: &CrateDetails,
117    fallback: bool,
118) -> Result<InstallSuccess, InstallError> {
119    let urls = get_quickinstall_download_urls(details);
120
121    let res = match curl_and_untar(&urls[0]) {
122        Err(err) if err.is_curl_404() => {
123            println!("Fallback to old release schema");
124
125            curl_and_untar(&urls[1])
126        }
127        res => res,
128    };
129
130    match res {
131        Ok(tar_output) => {
132            let bin_dir = get_cargo_bin_dir()?;
133
134            // tar output contains its own newline.
135            print!(
136                "Installed {crate_name}@{version} to {bin_dir}:\n{tar_output}",
137                crate_name = details.crate_name,
138                version = details.version,
139                bin_dir = bin_dir.display(),
140            );
141            Ok(InstallSuccess::InstalledFromTarball)
142        }
143        Err(err) if err.is_curl_404() => {
144            if !fallback {
145                return Err(InstallError::NoFallback(details.clone()));
146            }
147
148            println!(
149                "Could not find a pre-built package for {} {} on {}.",
150                details.crate_name, details.version, details.target
151            );
152            println!("We have reported your installation request, so it should be built soon.");
153
154            println!("Falling back to `cargo install`.");
155
156            let status = prepare_cargo_install_cmd(details).status()?;
157
158            if status.success() {
159                Ok(InstallSuccess::BuiltFromSource)
160            } else {
161                Err(InstallError::CargoInstallFailed)
162            }
163        }
164        Err(err) => Err(err),
165    }
166}
167
168fn curl_and_untar(url: &str) -> Result<String, InstallError> {
169    untar(curl(url)?)
170}
171
172pub fn get_latest_version(crate_name: &str) -> Result<String, InstallError> {
173    let url = format!("https://crates.io/api/v1/crates/{crate_name}");
174
175    curl_json(&url)
176        .map_err(|e| {
177            if e.is_curl_404() {
178                InstallError::CrateDoesNotExist {
179                    crate_name: crate_name.to_string(),
180                }
181            } else {
182                e
183            }
184        })?
185        .get_owned(&"crate")?
186        .get_owned(&"max_stable_version")?
187        .try_into_string()
188        .map_err(From::from)
189}
190
191pub fn get_target_triple() -> Result<String, InstallError> {
192    match get_target_triple_from_rustc() {
193        Ok(target) => Ok(target),
194        Err(err) => {
195            if let Some(target) = guess_host_triple() {
196                println!("get_target_triple_from_rustc() failed due to {err}, fallback to guess_host_triple");
197                Ok(target.to_string())
198            } else {
199                println!("get_target_triple_from_rustc() failed due to {err}, fallback to guess_host_triple also failed");
200                Err(err)
201            }
202        }
203    }
204}
205
206fn get_target_triple_from_rustc() -> Result<String, InstallError> {
207    // Credit to https://stackoverflow.com/a/63866386
208    let output = std::process::Command::new("rustc")
209        .arg("--version")
210        .arg("--verbose")
211        .output_checked_status()?;
212
213    let stdout = String::from_utf8_lossy(&output.stdout);
214    let target = stdout
215        .lines()
216        .find_map(|line| line.strip_prefix("host: "))
217        .ok_or(InstallError::FailToParseRustcOutput {
218            reason: "Fail to find any line starts with 'host: '.",
219        })?;
220
221    // The target triplets have the form of 'arch-vendor-system'.
222    //
223    // When building for Linux (e.g. the 'system' part is
224    // 'linux-something'), replace the vendor with 'unknown'
225    // so that mapping to rust standard targets happens correctly.
226    //
227    // For example, alpine set `rustc` host triple to
228    // `x86_64-alpine-linux-musl`.
229    //
230    // Here we use splitn with n=4 since we just need to check
231    // the third part to see if it equals to "linux" and verify
232    // that we have at least three parts.
233    let mut parts: Vec<&str> = target.splitn(4, '-').collect();
234    let os = *parts.get(2).ok_or(InstallError::FailToParseRustcOutput {
235        reason: "rustc returned an invalid triple, contains less than three parts",
236    })?;
237    if os == "linux" {
238        parts[1] = "unknown";
239    }
240    Ok(parts.join("-"))
241}
242
243pub fn report_stats_in_background(
244    details: &CrateDetails,
245    result: &Result<InstallSuccess, InstallError>,
246) {
247    let stats_url = format!(
248        "https://cargo-quickinstall-stats-server.fly.dev/record-install?crate={crate}&version={version}&target={target}&agent={agent}&status={status}",
249        crate = url_encode(&details.crate_name),
250        version = url_encode(&details.version),
251        target = url_encode(&details.target),
252        agent = url_encode(concat!("cargo-quickinstall/", env!("CARGO_PKG_VERSION"))),
253        status = install_result_to_status_str(result),
254    );
255
256    // Simply spawn the curl command to report stat.
257    //
258    // It's ok for it to fail and we would let the init process reap
259    // the `curl` process.
260    prepare_curl_post_cmd(&stats_url)
261        .stdin(process::Stdio::null())
262        .stdout(process::Stdio::null())
263        .stderr(process::Stdio::null())
264        .spawn()
265        .ok();
266}
267
268fn url_encode(input: &str) -> String {
269    let mut encoded = String::with_capacity(input.len());
270
271    for c in input.chars() {
272        match c {
273            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => encoded.push(c),
274            _ => encoded.push_str(&format!("%{:02X}", c as u8)),
275        }
276    }
277
278    encoded
279}
280
281fn format_curl_and_untar_cmd(url: &str, bin_dir: &Path) -> String {
282    format!(
283        "{curl_cmd} | {untar_cmd}",
284        curl_cmd = prepare_curl_bytes_cmd(url).formattable(),
285        untar_cmd = prepare_untar_cmd(bin_dir).formattable(),
286    )
287}
288
289pub fn do_dry_run_curl(
290    crate_details: &CrateDetails,
291    fallback: bool,
292) -> Result<String, InstallError> {
293    let urls = get_quickinstall_download_urls(crate_details);
294
295    let (url, res) = match curl_head(&urls[0]) {
296        Err(err) if err.is_curl_404() => (&urls[1], curl_head(&urls[1])),
297        res => (&urls[0], res),
298    };
299
300    match res {
301        Ok(_) => {
302            let cargo_bin_dir = get_cargo_bin_dir()?;
303
304            Ok(format_curl_and_untar_cmd(url, &cargo_bin_dir))
305        }
306        Err(err) if err.is_curl_404() && fallback => {
307            let cargo_install_cmd = prepare_cargo_install_cmd(crate_details);
308            Ok(format!("{}", cargo_install_cmd.formattable()))
309        }
310        Err(err) => Err(err),
311    }
312}
313
314fn untar(mut curl: ChildWithCommand) -> Result<String, InstallError> {
315    let bin_dir = get_cargo_bin_dir()?;
316
317    let res = prepare_untar_cmd(&bin_dir)
318        .stdin(curl.stdout().take().unwrap())
319        .output_checked_status();
320
321    // Propagate this error before Propagating error tar since
322    // if tar fails, it's likely due to curl failed.
323    //
324    // For example, this would enable the 404 error to be propagated
325    // correctly.
326    curl.wait_with_output_checked_status()?;
327
328    let output = res?;
329
330    let stdout = utf8_to_string_lossy(output.stdout);
331    let stderr = String::from_utf8_lossy(&output.stderr);
332
333    let mut s = stdout;
334    s += &stderr;
335
336    Ok(s)
337}
338
339fn prepare_curl_head_cmd(url: &str) -> std::process::Command {
340    let mut cmd = prepare_curl_cmd();
341    cmd.arg("--head").arg(url);
342    cmd
343}
344
345fn prepare_curl_post_cmd(url: &str) -> std::process::Command {
346    let mut cmd = prepare_curl_cmd();
347    cmd.args(["-X", "POST"]).arg(url);
348    cmd
349}
350
351fn curl_head(url: &str) -> Result<Vec<u8>, InstallError> {
352    prepare_curl_head_cmd(url)
353        .output_checked_status()
354        .map(|output| output.stdout)
355}
356
357fn curl(url: &str) -> Result<ChildWithCommand, InstallError> {
358    let mut cmd = prepare_curl_bytes_cmd(url);
359    cmd.stdin(process::Stdio::null())
360        .stdout(process::Stdio::piped())
361        .stderr(process::Stdio::piped());
362    cmd.spawn_with_cmd()
363}
364
365fn curl_file(url: &str, file: File) -> Result<(), InstallError> {
366    prepare_curl_bytes_cmd(url)
367        .stdin(process::Stdio::null())
368        .stdout(file)
369        .stderr(process::Stdio::piped())
370        .output_checked_status()?;
371
372    Ok(())
373}
374
375fn curl_bytes(url: &str) -> Result<Vec<u8>, InstallError> {
376    prepare_curl_bytes_cmd(url)
377        .output_checked_status()
378        .map(|output| output.stdout)
379}
380
381fn curl_string(url: &str) -> Result<String, InstallError> {
382    curl_bytes(url).map(utf8_to_string_lossy)
383}
384
385pub fn curl_json(url: &str) -> Result<JsonValue, InstallError> {
386    curl_string(url)?
387        .parse()
388        .map_err(|err| InstallError::InvalidJson {
389            url: url.to_string(),
390            err,
391        })
392}
393
394fn get_quickinstall_download_urls(
395    CrateDetails {
396        crate_name,
397        version,
398        target,
399    }: &CrateDetails,
400) -> [String; 2] {
401    [format!(
402        "https://github.com/cargo-bins/cargo-quickinstall/releases/download/{crate_name}-{version}/{crate_name}-{version}-{target}.tar.gz",
403    ),
404    format!(
405        "https://github.com/cargo-bins/cargo-quickinstall/releases/download/{crate_name}-{version}-{target}/{crate_name}-{version}-{target}.tar.gz",
406    )]
407}
408
409fn prepare_curl_cmd() -> std::process::Command {
410    let mut cmd = std::process::Command::new("curl");
411    cmd.args([
412        "--user-agent",
413        concat!(
414            "cargo-quickinstall/",
415            env!("CARGO_PKG_VERSION"),
416            " client (alsuren@gmail.com)",
417        ),
418        "--location",
419        "--silent",
420        "--show-error",
421        "--fail",
422    ]);
423    cmd
424}
425
426fn prepare_curl_bytes_cmd(url: &str) -> std::process::Command {
427    let mut cmd = prepare_curl_cmd();
428    cmd.arg(url);
429    cmd
430}
431
432fn prepare_untar_cmd(cargo_bin_dir: &Path) -> std::process::Command {
433    let mut cmd = std::process::Command::new("tar");
434    cmd.arg("-xzvvf").arg("-").arg("-C").arg(cargo_bin_dir);
435    cmd
436}
437
438fn prepare_cargo_install_cmd(details: &CrateDetails) -> std::process::Command {
439    let mut cmd = std::process::Command::new("cargo");
440    cmd.arg("install")
441        .arg(&details.crate_name)
442        .arg("--version")
443        .arg(&details.version);
444    cmd
445}