binswap_github/
lib.rs

1//! Download and swap binaries from GitHub
2//!
3//! # Usage
4//!
5//! `binswap` uses the same infrastructure as
6//! [`cargo-binstall`](https://github.com/cargo-bins/cargo-binstall) to
7//! determine where the latest binaries are stored. `binswap-github` is the
8//! backend to do this for GitHub specifically. It uses the GitHub releases to
9//! download binaries for a supported target, and then downloads them to a
10//! specified location, or optionally swaps them with the currently executed
11//! binary.
12//!
13//! This is particularly useful if you distribute binaries outside of package
14//! managers or in environments where the users are not expected to have Rust
15//! nor installed. With crate, you can bundle the updating mechanism into the
16//! distributed binary.
17//!
18//! # Example
19//!
20//! The following example downloads the latest release [`ripgrep` from
21//! GitHub](https://github.com/BurntSushi/ripgrep/releases), and swaps it with
22//! the currently executed binary. `.dry_run(true)` is added here to simulate
23//! the execution, but not perform the update.
24//!
25//! ```
26//! #[tokio::main]
27//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
28//!     binswap_github::builder()
29//!         .repo_author("BurntSushi")
30//!         .repo_name("ripgrep")
31//!         .asset_name("ripgrep")
32//!         .bin_name("rg")
33//!         .dry_run(true)
34//!         .build()?
35//!         .fetch_and_write_in_place_of_current_exec()
36//!         .await?;
37//!
38//!     Ok(())
39//! }
40//! ```
41//!
42//! The following does the same, but just writes the resulting binary to a new
43//! file.
44//!
45//! ```
46//! #[tokio::main]
47//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
48//!     binswap_github::builder()
49//!         .repo_author("BurntSushi")
50//!         .repo_name("ripgrep")
51//!         .asset_name("ripgrep")
52//!         .bin_name("rg")
53//!         .dry_run(true)
54//!         .build()?
55//!         .fetch_and_write_to("./rg")
56//!         .await?;
57//!
58//!     Ok(())
59//! }
60//! ```
61
62#![warn(missing_docs)]
63
64use std::{
65    borrow::Cow,
66    env,
67    io::{self, stderr, BufRead, StdinLock},
68    num::NonZeroU64,
69    path::Path,
70    sync::Arc,
71    thread,
72    time::Duration,
73};
74
75use binstalk::{
76    fetchers::{Data, Fetcher, GhCrateMeta, TargetData},
77    get_desired_targets,
78    helpers::{
79        download::ExtractedFilesEntry,
80        gh_api_client::GhApiClient,
81        remote::{Client, Url},
82    },
83    manifests::cargo_toml_binstall::PkgMeta,
84};
85use color_eyre::{
86    eyre::{eyre, Context},
87    Result,
88};
89use crossterm::{
90    cursor::{RestorePosition, SavePosition},
91    style::{Print, ResetColor, Stylize},
92    ExecutableCommand,
93};
94use derive_builder::Builder;
95use serde::Deserialize;
96use tokio::sync::oneshot;
97
98/// Create a new builder. Finish by calling `.build()`
99pub fn builder() -> BinswapGithubBuilder {
100    Default::default()
101}
102
103/// The parameters used to fetch and install binaries
104#[derive(Debug, Clone, Builder)]
105pub struct BinswapGithub {
106    /// The name of the author or team of the repository on GitHub.
107    #[builder(setter(into))]
108    repo_author: String,
109    /// The name of the repository on GitHub.
110    #[builder(setter(into))]
111    repo_name: String,
112    /// The name of the asset in the release. If not given `bin_name` will be
113    /// used.
114    #[builder(setter(into, strip_option), default)]
115    asset_name: Option<String>,
116    /// The name of the binary in the release.
117    #[builder(setter(into))]
118    bin_name: String,
119    /// The desired version to download. If not given the latest will be used.
120    #[builder(setter(into, strip_option), default)]
121    version: Option<String>,
122    /// Do not prompt user for confirmation before installing.
123    #[builder(setter(into), default = "false")]
124    no_confirm: bool,
125    /// The command to run to check that the binary is executable before
126    /// installing it.
127    #[builder(setter(into), default = "\"--help\".to_string()")]
128    check_with_cmd: String,
129    /// Do not run the check command before installing.
130    #[builder(setter(into), default = "false")]
131    no_check_with_cmd: bool,
132    /// Determine and download binary, but do not install it.
133    #[builder(setter(into), default = "false")]
134    dry_run: bool,
135    /// The possible targets to download. If provided, targets will not be
136    /// auto-detected.
137    #[builder(setter(into, strip_option), default)]
138    targets: Option<Vec<String>>,
139}
140
141impl BinswapGithubBuilder {
142    /// Add the target to list of possible targets to download. If provided,
143    /// targets will not be auto-detected.
144    pub fn add_target(&mut self, target: impl Into<String>) -> &mut Self {
145        self.targets
146            .get_or_insert_with(|| Some(vec![]))
147            .as_mut()
148            .unwrap()
149            .push(target.into());
150        self
151    }
152}
153
154impl BinswapGithub {
155    /// Downloads and writes the found binary to the location of the currently
156    /// executed binary in-place.
157    ///
158    /// ### Warning
159    ///
160    /// This action alters the binary and is **not reversible**!
161    pub async fn fetch_and_write_in_place_of_current_exec(&self) -> Result<()> {
162        self.fetch_and_write_to(std::env::current_exe()?).await
163    }
164    /// Downloads and writes the found binary to the specified location.
165    pub async fn fetch_and_write_to(&self, target_binary: impl AsRef<Path>) -> Result<()> {
166        let target_binary = target_binary.as_ref();
167
168        let name = target_binary
169            .file_name()
170            .ok_or_else(|| eyre!("target file had no name"))?
171            .to_str()
172            .unwrap();
173
174        let temp = tempfile::Builder::new().prefix("binswap").tempdir()?;
175
176        let client = Client::new(
177            concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")),
178            None,
179            Duration::from_millis(5),
180            NonZeroU64::new(1).unwrap(),
181            None,
182        )?;
183
184        let gh_api_client = GhApiClient::new(
185            client.clone(),
186            env::var("GH_TOKEN")
187                .or_else(|_| env::var("GITHUB_TOKEN"))
188                .ok()
189                .map(Into::into),
190        );
191
192        stderr()
193            .execute(Print("Updating ".green()))?
194            .execute(Print(&name))?
195            .execute(Print("...\n".green()))?
196            .execute(ResetColor)?;
197
198        let version = if let Some(v) = self.version.clone() {
199            v
200        } else {
201            #[derive(Debug, Deserialize)]
202            struct Response {
203                tag_name: String,
204            }
205
206            stderr()
207                .execute(Print(
208                    "Getting latest version number...\n".magenta().italic(),
209                ))?
210                .execute(ResetColor)?;
211
212            let res: Response = client
213                .get(Url::parse(&format!(
214                    "https://api.github.com/repos/{}/{}/releases/latest",
215                    self.repo_author, self.repo_name
216                ))?)
217                .send(true)
218                .await?
219                .json()
220                .await?;
221            res.tag_name.trim_start_matches('v').to_string()
222        };
223
224        stderr()
225            .execute(Print("Using version ".green()))?
226            .execute(Print(&version))?
227            .execute(Print("\n"))?
228            .execute(ResetColor)?;
229
230        let targets = if let Some(targets) = self.targets.clone() {
231            targets
232        } else {
233            get_desired_targets(None).get().await.to_vec()
234        };
235        let data = Arc::new(Data::new(
236            self.asset_name
237                .as_deref()
238                .map(Into::into)
239                .unwrap_or_else(|| self.bin_name.as_str().into()),
240            version.into(),
241            Some(format!(
242                "https://github.com/{}/{}/",
243                self.repo_author, self.repo_name
244            )),
245        ));
246        for target in &targets {
247            let resolver = GhCrateMeta::new(
248                client.clone(),
249                gh_api_client.clone(),
250                data.clone(),
251                Arc::new(TargetData {
252                    target: target.into(),
253                    meta: PkgMeta::default(),
254                }),
255            );
256
257            stderr()
258                .execute(Print("Looking for binary for target ".magenta().italic()))?
259                .execute(Print(&target))?
260                .execute(Print("...\n".magenta().italic()))?;
261
262            let found = Arc::clone(&resolver).find().await??;
263            if !found {
264                continue;
265            }
266
267            stderr().execute(Print("Found a binary! Downloading...\n".magenta().italic()))?;
268
269            let extracted_files = resolver.fetch_and_extract(temp.path()).await?;
270
271            let bin_name = Path::new(&self.bin_name);
272
273            let bin_name = if target.contains("windows") {
274                Cow::Owned(bin_name.with_extension("exe"))
275            } else {
276                Cow::Borrowed(bin_name)
277            };
278
279            let bin_path = if extracted_files.has_file(&bin_name) {
280                temp.path().join(&bin_name)
281            } else {
282                let res = (|| {
283                    let entries = extracted_files.get_dir(Path::new("."))?;
284                    for entry in entries {
285                        if let Some(ExtractedFilesEntry::Dir(entries)) =
286                            extracted_files.get_entry(Path::new(entry))
287                        {
288                            if entries.contains(bin_name.as_os_str()) {
289                                let mut p = temp.path().join(Path::new(&**entry));
290                                p.push(&bin_name);
291                                return Some(p);
292                            }
293                        }
294                    }
295
296                    None
297                })();
298
299                if let Some(bin_path) = res {
300                    bin_path
301                } else {
302                    stderr().execute(Print(
303                        " > No binary found in asset, trying next target...\n"
304                            .red()
305                            .italic(),
306                    ))?;
307                    continue;
308                }
309            };
310
311            if !self.no_check_with_cmd {
312                let res = tokio::process::Command::new(&bin_path)
313                    .arg(&self.check_with_cmd)
314                    .output()
315                    .await?;
316                if !res.status.success() {
317                    return Err(eyre!(
318                        "Could not execute `{}` on downloaded binary: {}",
319                        self.check_with_cmd,
320                        res.status,
321                    ));
322                }
323            }
324
325            stderr()
326                .execute(Print("\n  About to write binary to ".green()))?
327                .execute(Print(format!("`{}`\n", target_binary.display())))?;
328
329            if self.no_confirm || confirm().await {
330                if !self.dry_run {
331                    let backup_bin = temp.path().join("backup-binary");
332
333                    // NOTE: Swapping procedure:
334                    // - Move the old binary into a temp folder
335                    // - Move the new binary into target destination, which
336                    //   should now be vacant
337                    //   - If this fails, move the old binary back
338                    // - The temp folder will be dropped at the end of
339                    //   scope, removing the old binary
340                    tokio::fs::rename(target_binary, &backup_bin)
341                        .await
342                        .wrap_err("failed to move old binary before updating to new")?;
343                    if let Err(e) = tokio::fs::rename(bin_path, target_binary).await {
344                        if let Err(e2) = tokio::fs::rename(backup_bin, target_binary).await {
345                            let error_msg = "failed to move old binary back after failing to move new binary into target destination";
346                            return Err(e2).wrap_err(error_msg).wrap_err(e);
347                        } else {
348                            return Err(e)
349                                .wrap_err("failed to put new binary into target destination");
350                        }
351                    }
352                }
353
354                stderr()
355                    .execute(Print("\n".green()))?
356                    .execute(Print(&name))?
357                    .execute(Print(" has been updated!".green()))?
358                    .execute(Print(
359                        if self.dry_run {
360                            " (not actually since it was a dry-run)"
361                        } else {
362                            ""
363                        }
364                        .dim(),
365                    ))?
366                    .execute(Print("\n"))?
367                    .execute(ResetColor)?;
368            } else {
369                return Ok(());
370            }
371
372            return Ok(());
373        }
374
375        drop(temp);
376
377        Err(eyre!("not found"))
378    }
379}
380
381fn ask_for_confirm(stdin: &mut StdinLock, input: &mut String) -> io::Result<()> {
382    stderr()
383        .execute(Print("\n  Do you wish to continue? ".yellow()))?
384        .execute(Print("yes/[no]\n"))?
385        .execute(Print("  ? ".dim()))?
386        .execute(SavePosition)?
387        .execute(Print("\n"))?
388        .execute(RestorePosition)?;
389
390    stdin.read_line(input)?;
391
392    Ok(())
393}
394
395async fn confirm() -> bool {
396    let (tx, rx) = oneshot::channel();
397
398    thread::spawn(move || {
399        // This task should be the only one able to
400        // access stdin
401        let mut stdin = io::stdin().lock();
402        let mut input = String::with_capacity(16);
403
404        let res = loop {
405            if ask_for_confirm(&mut stdin, &mut input).is_err() {
406                break false;
407            }
408
409            match input.as_str().trim() {
410                "yes" | "y" | "YES" | "Y" => break true,
411                "no" | "n" | "NO" | "N" | "" => break false,
412                _ => {
413                    input.clear();
414                    continue;
415                }
416            }
417        };
418
419        // The main thread might be terminated by signal and thus cancelled
420        // the confirmation.
421        tx.send(res).ok();
422    });
423
424    rx.await.unwrap()
425}