Skip to main content

scope/http/
ghola_install.rs

1//! # Ghola install
2//!
3//! Finds the [Ghola](https://github.com/robot-accomplice/ghola) sidecar
4//! binary and installs it from the project's GitHub releases.
5//!
6//! Nothing here asks the user: the CLI asks first and calls
7//! [`install_latest`] only on a yes.
8//!
9//! An install takes the latest release of `robot-accomplice/ghola`, picks
10//! the archive for this OS and CPU (`ghola_<ver>_<os>_<arch>.tar.gz`, or
11//! `.zip` on Windows), checks its SHA-256 against the release's
12//! `checksums.txt`, and only then extracts `ghola` (`ghola.exe`) from the
13//! archive root to [`install_dir`]. The file is written to a temporary
14//! name and renamed into place. PATH is never changed: scope runs the
15//! binary by its full path.
16//!
17//! `checksums.txt` is not signed. The check proves the archive is the one
18//! the release lists; HTTPS to GitHub is what ties the release to its
19//! publisher. Every request, redirects included, must be HTTPS to a GitHub
20//! host.
21//!
22//! The download goes through a [`ReleaseSource`], so tests use a fake
23//! release and never touch the network.
24
25use crate::error::ScopeError;
26use async_trait::async_trait;
27use serde::Deserialize;
28use sha2::{Digest, Sha256};
29use std::ffi::OsStr;
30use std::io::Read;
31use std::path::{Path, PathBuf};
32use std::process::Command;
33use std::time::Duration;
34
35/// The GitHub API URL of the latest release.
36pub const LATEST_RELEASE_URL: &str =
37    "https://api.github.com/repos/robot-accomplice/ghola/releases/latest";
38
39/// The releases page, for installing by hand.
40pub const RELEASES_PAGE: &str = "https://github.com/robot-accomplice/ghola/releases";
41
42/// The Go package of the binary. The module root has no main package.
43pub const GO_PACKAGE: &str = "github.com/robot-accomplice/ghola/cmd/ghola@latest";
44
45/// The command that builds Ghola from source.
46pub const GO_INSTALL: &str = "go install github.com/robot-accomplice/ghola/cmd/ghola@latest";
47
48/// The hosts a download may use: the API, the release page and GitHub's
49/// asset stores, where release downloads redirect.
50const ALLOWED_HOSTS: &[&str] = &[
51    "api.github.com",
52    "github.com",
53    "objects.githubusercontent.com",
54    "release-assets.githubusercontent.com",
55];
56
57/// The largest download accepted (the archives are about 5 MB).
58const MAX_DOWNLOAD_BYTES: usize = 100 * 1024 * 1024;
59
60/// The largest binary extracted (ghola is about 13 MB).
61const MAX_BINARY_BYTES: u64 = 200 * 1024 * 1024;
62
63/// How long one download may take.
64const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120);
65
66/// The release file that lists each asset's SHA-256.
67const CHECKSUMS: &str = "checksums.txt";
68
69/// Where a download comes from.
70#[async_trait]
71pub trait ReleaseSource: Send + Sync {
72    /// The body of an HTTPS GET of `url`.
73    async fn fetch(&self, url: &str) -> Result<Vec<u8>, ScopeError>;
74}
75
76/// Downloads from GitHub over HTTPS. Any other URL, and any redirect to
77/// one, is refused.
78pub struct GithubReleases {
79    client: reqwest::Client,
80    /// The URLs it fetches, redirects included: [`allowed_url`].
81    allowed: fn(&str) -> bool,
82}
83
84impl GithubReleases {
85    /// A source for GitHub releases.
86    ///
87    /// # Errors
88    ///
89    /// Returns `Network` when the client cannot be built.
90    pub fn new() -> Result<Self, ScopeError> {
91        Self::with_policy(allowed_url, true)
92    }
93
94    /// A source that fetches only the URLs `allowed` accepts, over HTTPS
95    /// only when `https_only`. Tests point it at a local mock server.
96    fn with_policy(allowed: fn(&str) -> bool, https_only: bool) -> Result<Self, ScopeError> {
97        let redirects = reqwest::redirect::Policy::custom(move |attempt| {
98            if attempt.previous().len() >= 10 {
99                attempt.error("too many redirects")
100            } else if allowed(attempt.url().as_str()) {
101                attempt.follow()
102            } else {
103                let to = attempt.url().to_string();
104                attempt.error(format!("redirect to {to} is not a GitHub HTTPS URL"))
105            }
106        });
107        let client = reqwest::Client::builder()
108            .https_only(https_only)
109            .redirect(redirects)
110            .timeout(DOWNLOAD_TIMEOUT)
111            .user_agent(concat!("scope-bca/", env!("CARGO_PKG_VERSION")))
112            .build()
113            .map_err(|e| {
114                ScopeError::Network(format!("failed to build the download client: {e}"))
115            })?;
116        Ok(Self { client, allowed })
117    }
118}
119
120#[async_trait]
121impl ReleaseSource for GithubReleases {
122    async fn fetch(&self, url: &str) -> Result<Vec<u8>, ScopeError> {
123        if !(self.allowed)(url) {
124            return Err(ScopeError::Network(format!(
125                "refusing to download {url}: not a GitHub HTTPS URL"
126            )));
127        }
128        let resp = self
129            .client
130            .get(url)
131            .send()
132            .await
133            .map_err(|e| ScopeError::Network(format!("download of {url} failed: {e}")))?;
134        let status = resp.status();
135        if !status.is_success() {
136            return Err(ScopeError::Network(format!(
137                "download of {url} failed: HTTP {status}"
138            )));
139        }
140        if resp
141            .content_length()
142            .is_some_and(|n| n > MAX_DOWNLOAD_BYTES as u64)
143        {
144            return Err(too_large(url));
145        }
146        let body = resp
147            .bytes()
148            .await
149            .map_err(|e| ScopeError::Network(format!("download of {url} failed: {e}")))?;
150        if body.len() > MAX_DOWNLOAD_BYTES {
151            return Err(too_large(url));
152        }
153        Ok(body.to_vec())
154    }
155}
156
157fn too_large(url: &str) -> ScopeError {
158    ScopeError::Network(format!(
159        "download of {url} is larger than {MAX_DOWNLOAD_BYTES} bytes"
160    ))
161}
162
163/// True when `url` is HTTPS to a GitHub host that serves releases.
164pub fn allowed_url(url: &str) -> bool {
165    reqwest::Url::parse(url).is_ok_and(|u| {
166        u.scheme() == "https" && u.host_str().is_some_and(|h| ALLOWED_HOSTS.contains(&h))
167    })
168}
169
170/// An OS and CPU, in the names Ghola's release archives use.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub struct Target {
173    pub os: &'static str,
174    pub arch: &'static str,
175}
176
177impl Target {
178    /// This machine, when Ghola publishes for it.
179    pub fn current() -> Option<Self> {
180        Self::from_rust(std::env::consts::OS, std::env::consts::ARCH)
181    }
182
183    /// The target for Rust's OS and arch names, when Ghola publishes for it.
184    pub fn from_rust(os: &str, arch: &str) -> Option<Self> {
185        let os = match os {
186            "linux" => "linux",
187            "macos" => "darwin",
188            "windows" => "windows",
189            _ => return None,
190        };
191        let arch = match arch {
192            "x86_64" => "amd64",
193            "aarch64" => "arm64",
194            _ => return None,
195        };
196        Some(Self { os, arch })
197    }
198
199    fn is_zip(self) -> bool {
200        self.os == "windows"
201    }
202
203    /// The release asset for `version` (without the leading `v`).
204    pub fn asset_name(self, version: &str) -> String {
205        let ext = if self.is_zip() { "zip" } else { "tar.gz" };
206        format!("ghola_{version}_{}_{}.{ext}", self.os, self.arch)
207    }
208
209    /// The binary's file name in the archive.
210    pub fn binary_name(self) -> &'static str {
211        if self.is_zip() { "ghola.exe" } else { "ghola" }
212    }
213}
214
215#[derive(Deserialize)]
216struct Release {
217    tag_name: String,
218    #[serde(default)]
219    assets: Vec<Asset>,
220}
221
222#[derive(Deserialize)]
223struct Asset {
224    name: String,
225    browser_download_url: String,
226}
227
228/// What [`install_latest`] did.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum Outcome {
231    /// The binary is at `path`.
232    Installed { path: PathBuf, version: String },
233    /// The latest release has no archive for the target.
234    NoAsset { version: String, asset: String },
235}
236
237/// Installs the latest Ghola release for `target` into `dir`.
238///
239/// Nothing is extracted or written before the archive's SHA-256 matches
240/// `checksums.txt`.
241///
242/// # Errors
243///
244/// Returns `Network` when a download fails, the release has no
245/// `checksums.txt` or does not list the archive, or the checksum does not
246/// match; `IoError` when the binary cannot be written.
247pub async fn install_latest(
248    source: &dyn ReleaseSource,
249    target: Target,
250    dir: &Path,
251) -> Result<Outcome, ScopeError> {
252    let body = source.fetch(LATEST_RELEASE_URL).await?;
253    let release: Release = serde_json::from_slice(&body)
254        .map_err(|e| ScopeError::Network(format!("unreadable ghola release: {e}")))?;
255    let version = release.tag_name.trim_start_matches('v').to_string();
256    let name = target.asset_name(&version);
257    let Some(archive) = release.assets.iter().find(|a| a.name == name) else {
258        return Ok(Outcome::NoAsset {
259            version,
260            asset: name,
261        });
262    };
263    let sums = release
264        .assets
265        .iter()
266        .find(|a| a.name == CHECKSUMS)
267        .ok_or_else(|| {
268            ScopeError::Network(format!(
269                "ghola v{version} has no {CHECKSUMS}; not installing an unchecked file"
270            ))
271        })?;
272    let sums = source.fetch(&sums.browser_download_url).await?;
273    let want = expected_sha256(&String::from_utf8_lossy(&sums), &name).ok_or_else(|| {
274        ScopeError::Network(format!(
275            "{CHECKSUMS} of ghola v{version} does not list {name}"
276        ))
277    })?;
278    let bytes = source.fetch(&archive.browser_download_url).await?;
279    let got: [u8; 32] = Sha256::digest(&bytes).into();
280    if got != want {
281        return Err(ScopeError::Network(format!(
282            "{name} does not match its SHA-256 in {CHECKSUMS} (expected {}, got {}); not installing it",
283            hex::encode(want),
284            hex::encode(got)
285        )));
286    }
287    let binary = extract_binary(&bytes, target)?;
288    let path = write_executable(dir, target.binary_name(), &binary)?;
289    Ok(Outcome::Installed { path, version })
290}
291
292/// The SHA-256 that `checksums.txt` (`<hex>  <file>` lines) gives for `file`.
293pub fn expected_sha256(sums: &str, file: &str) -> Option<[u8; 32]> {
294    sums.lines().find_map(|line| {
295        let mut parts = line.split_whitespace();
296        let hash = parts.next()?;
297        let name = parts.next()?.trim_start_matches('*');
298        if name != file || parts.next().is_some() {
299            return None;
300        }
301        hex::decode(hash).ok()?.try_into().ok()
302    })
303}
304
305/// The binary at the root of a verified archive.
306fn extract_binary(archive: &[u8], target: Target) -> Result<Vec<u8>, ScopeError> {
307    let name = target.binary_name();
308    let found = if target.is_zip() {
309        extract_from_zip(archive, name)?
310    } else {
311        extract_from_tar_gz(archive, name)?
312    };
313    found.ok_or_else(|| ScopeError::Network(format!("the ghola archive has no {name} at its root")))
314}
315
316fn archive_error(e: impl std::fmt::Display) -> ScopeError {
317    ScopeError::Network(format!("unreadable ghola archive: {e}"))
318}
319
320fn read_limited(entry: impl Read) -> Result<Vec<u8>, ScopeError> {
321    let mut buf = Vec::new();
322    entry
323        .take(MAX_BINARY_BYTES + 1)
324        .read_to_end(&mut buf)
325        .map_err(archive_error)?;
326    if buf.len() as u64 > MAX_BINARY_BYTES {
327        return Err(archive_error("the binary is too large"));
328    }
329    Ok(buf)
330}
331
332fn extract_from_tar_gz(archive: &[u8], name: &str) -> Result<Option<Vec<u8>>, ScopeError> {
333    let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(archive));
334    for entry in tar.entries().map_err(archive_error)? {
335        let entry = entry.map_err(archive_error)?;
336        if !entry.header().entry_type().is_file() {
337            continue;
338        }
339        let path = entry.path().map_err(archive_error)?;
340        if path.strip_prefix(".").unwrap_or(&path) == Path::new(name) {
341            return read_limited(entry).map(Some);
342        }
343    }
344    Ok(None)
345}
346
347fn extract_from_zip(archive: &[u8], name: &str) -> Result<Option<Vec<u8>>, ScopeError> {
348    let mut zip = zip::ZipArchive::new(std::io::Cursor::new(archive)).map_err(archive_error)?;
349    for i in 0..zip.len() {
350        let file = zip.by_index(i).map_err(archive_error)?;
351        if file.is_file() && file.name().is_ok_and(|n| n == name) {
352            return read_limited(file).map(Some);
353        }
354    }
355    Ok(None)
356}
357
358/// Writes `bytes` to `dir/name` through a temporary file and a rename,
359/// executable on Unix.
360fn write_executable(dir: &Path, name: &str, bytes: &[u8]) -> Result<PathBuf, ScopeError> {
361    let context =
362        |e| ScopeError::io_context(e, format!("cannot install ghola in {}", dir.display()));
363    std::fs::create_dir_all(dir).map_err(context)?;
364    let tmp = dir.join(format!(".{name}.{}.part", std::process::id()));
365    let dest = dir.join(name);
366    let written = std::fs::write(&tmp, bytes)
367        .and_then(|()| make_executable(&tmp))
368        .and_then(|()| std::fs::rename(&tmp, &dest));
369    if let Err(e) = written {
370        let _ = std::fs::remove_file(&tmp);
371        return Err(context(e));
372    }
373    Ok(dest)
374}
375
376#[cfg(unix)]
377fn make_executable(path: &Path) -> std::io::Result<()> {
378    use std::os::unix::fs::PermissionsExt;
379    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
380}
381
382#[cfg(not(unix))]
383fn make_executable(_: &Path) -> std::io::Result<()> {
384    Ok(())
385}
386
387/// Where scope installs Ghola: `<scope home>/bin`.
388pub fn install_dir() -> PathBuf {
389    crate::paths::home().join("bin")
390}
391
392/// The Ghola binary: on PATH, else in [`install_dir`].
393pub fn find() -> Option<PathBuf> {
394    locate(std::env::var_os("PATH").as_deref(), &install_dir())
395}
396
397/// The Ghola binary: in a `path` folder, else in `dir`.
398pub fn locate(path: Option<&OsStr>, dir: &Path) -> Option<PathBuf> {
399    path.and_then(|p| in_path(p, "ghola")).or_else(|| {
400        let file = dir.join(format!("ghola{}", std::env::consts::EXE_SUFFIX));
401        file.is_file().then_some(file)
402    })
403}
404
405/// The program `name` in one of the `path` folders, with the platform's
406/// executable suffix (`ghola.exe` on Windows). It looks in the folders
407/// itself: Windows has no `which`.
408pub fn in_path(path: &OsStr, name: &str) -> Option<PathBuf> {
409    let file = format!("{name}{}", std::env::consts::EXE_SUFFIX);
410    std::env::split_paths(path)
411        .map(|dir| dir.join(&file))
412        .find(|p| p.is_file())
413}
414
415/// `go` on PATH, for building Ghola when no release archive fits.
416pub fn find_go() -> Option<PathBuf> {
417    std::env::var_os("PATH").and_then(|p| in_path(&p, "go"))
418}
419
420/// Builds Ghola with `go install` into `dir` (through `GOBIN`) and returns
421/// the binary's path.
422///
423/// # Errors
424///
425/// Returns `IoError` when `go` cannot run, `Other` when it fails.
426pub fn go_install(go: &Path, dir: &Path) -> Result<PathBuf, ScopeError> {
427    std::fs::create_dir_all(dir)
428        .map_err(|e| ScopeError::io_context(e, format!("cannot create {}", dir.display())))?;
429    let status = Command::new(go)
430        .args(["install", GO_PACKAGE])
431        .env("GOBIN", dir)
432        .status()
433        .map_err(|e| ScopeError::io_context(e, format!("cannot run {}", go.display())))?;
434    if !status.success() {
435        return Err(ScopeError::Other(format!(
436            "`{GO_INSTALL}` failed ({status})"
437        )));
438    }
439    let path = dir.join(format!("ghola{}", std::env::consts::EXE_SUFFIX));
440    if path.is_file() {
441        Ok(path)
442    } else {
443        Err(ScopeError::Other(format!(
444            "`{GO_INSTALL}` did not write {}",
445            path.display()
446        )))
447    }
448}
449
450/// How to install Ghola by hand, one line each.
451pub fn manual_steps() -> Vec<String> {
452    vec![
453        "Run `scope setup`: it offers to download ghola from its GitHub releases.".into(),
454        format!(
455            "Or download the archive for this system from {RELEASES_PAGE} and put ghola in {}",
456            install_dir().display()
457        ),
458        format!("Or build it: {GO_INSTALL}"),
459    ]
460}
461
462/// A release served from memory and archive builders, for the tests here
463/// and in the CLI. Nothing in it touches the network.
464#[cfg(any(test, feature = "test-support"))]
465pub mod test_support {
466    use super::{CHECKSUMS, LATEST_RELEASE_URL, ReleaseSource, Target};
467    use crate::error::ScopeError;
468    use async_trait::async_trait;
469    use sha2::{Digest, Sha256};
470    use std::collections::HashMap;
471    use std::io::Write;
472    use std::sync::Mutex;
473
474    /// The binary in the fake archives.
475    pub const BINARY: &[u8] = b"\x7fELF fake ghola";
476
477    /// A release served from memory. It records each URL fetched.
478    pub struct FakeRelease {
479        pub files: HashMap<String, Vec<u8>>,
480        pub fetched: Mutex<Vec<String>>,
481    }
482
483    #[async_trait]
484    impl ReleaseSource for FakeRelease {
485        async fn fetch(&self, url: &str) -> Result<Vec<u8>, ScopeError> {
486            self.fetched.lock().unwrap().push(url.to_string());
487            self.files
488                .get(url)
489                .cloned()
490                .ok_or_else(|| ScopeError::Network(format!("HTTP 404 for {url}")))
491        }
492    }
493
494    pub fn url(file: &str) -> String {
495        format!("https://github.com/robot-accomplice/ghola/releases/download/v0.6.2/{file}")
496    }
497
498    pub fn tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
499        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
500        let mut tar = tar::Builder::new(gz);
501        for (name, data) in entries {
502            let mut header = tar::Header::new_gnu();
503            header.set_size(data.len() as u64);
504            header.set_mode(0o755);
505            header.set_cksum();
506            tar.append_data(&mut header, name, *data).unwrap();
507        }
508        tar.into_inner().unwrap().finish().unwrap()
509    }
510
511    pub fn zip_of(entries: &[(&str, &[u8])]) -> Vec<u8> {
512        let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
513        for (name, data) in entries {
514            zip.start_file(*name, zip::write::SimpleFileOptions::default())
515                .unwrap();
516            zip.write_all(data).unwrap();
517        }
518        zip.finish().unwrap().into_inner()
519    }
520
521    /// A v0.6.2 release with `archive` (named for `target`) and a
522    /// `checksums.txt` that gives `listed` as its hash.
523    pub fn release(target: Target, archive: Vec<u8>, listed: Option<String>) -> FakeRelease {
524        let name = target.asset_name("0.6.2");
525        let hash = listed.unwrap_or_else(|| hex::encode(Sha256::digest(&archive)));
526        let sums = format!("{}  other.deb\n{hash}  {name}\n", "0".repeat(64));
527        let json = serde_json::json!({
528            "tag_name": "v0.6.2",
529            "assets": [
530                {"name": CHECKSUMS, "browser_download_url": url(CHECKSUMS)},
531                {"name": name, "browser_download_url": url(&name)},
532            ]
533        });
534        let files = HashMap::from([
535            (
536                LATEST_RELEASE_URL.to_string(),
537                json.to_string().into_bytes(),
538            ),
539            (url(CHECKSUMS), sums.into_bytes()),
540            (url(&name), archive),
541        ]);
542        FakeRelease {
543            files,
544            fetched: Mutex::default(),
545        }
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::test_support::*;
552    use super::*;
553
554    const LINUX: Target = Target {
555        os: "linux",
556        arch: "amd64",
557    };
558    const WINDOWS: Target = Target {
559        os: "windows",
560        arch: "amd64",
561    };
562
563    #[test]
564    fn test_target_names_match_the_release_assets() {
565        let mac = Target::from_rust("macos", "aarch64").unwrap();
566        assert_eq!(mac.asset_name("0.6.2"), "ghola_0.6.2_darwin_arm64.tar.gz");
567        assert_eq!(mac.binary_name(), "ghola");
568        let win = Target::from_rust("windows", "x86_64").unwrap();
569        assert_eq!(win.asset_name("0.6.2"), "ghola_0.6.2_windows_amd64.zip");
570        assert_eq!(win.binary_name(), "ghola.exe");
571        assert_eq!(Target::from_rust("freebsd", "x86_64"), None);
572        assert_eq!(Target::from_rust("linux", "riscv64"), None);
573    }
574
575    #[test]
576    fn test_only_github_https_urls_are_allowed() {
577        assert!(allowed_url(LATEST_RELEASE_URL));
578        assert!(allowed_url(&url("checksums.txt")));
579        assert!(allowed_url(
580            "https://release-assets.githubusercontent.com/x?sig=1"
581        ));
582        assert!(!allowed_url("http://github.com/robot-accomplice/ghola"));
583        assert!(!allowed_url("https://github.com.evil.example/x"));
584        assert!(!allowed_url("https://example.com/ghola.tar.gz"));
585        assert!(!allowed_url("not a url"));
586    }
587
588    #[test]
589    fn test_expected_sha256_reads_the_named_line() {
590        let a = "ab".repeat(32);
591        let sums = format!("{}  ghola_x.zip\n{a}  ghola_y.tar.gz\n", "cd".repeat(32));
592        assert_eq!(expected_sha256(&sums, "ghola_y.tar.gz"), Some([0xab; 32]));
593        assert_eq!(
594            expected_sha256(&format!("{a} *f.zip"), "f.zip"),
595            Some([0xab; 32])
596        );
597        assert_eq!(expected_sha256(&sums, "missing.zip"), None);
598        assert_eq!(expected_sha256("zz  f.zip", "f.zip"), None);
599    }
600
601    #[tokio::test]
602    async fn test_install_tar_gz_writes_only_the_binary() {
603        let dir = tempfile::tempdir().unwrap();
604        let archive = tar_gz(&[("LICENSE", b"MIT"), ("ghola", BINARY)]);
605        let source = release(LINUX, archive, None);
606        let outcome = install_latest(&source, LINUX, dir.path()).await.unwrap();
607        let path = dir.path().join("ghola");
608        assert_eq!(
609            outcome,
610            Outcome::Installed {
611                path: path.clone(),
612                version: "0.6.2".into()
613            }
614        );
615        assert_eq!(std::fs::read(&path).unwrap(), BINARY);
616        let names: Vec<_> = std::fs::read_dir(dir.path())
617            .unwrap()
618            .map(|e| e.unwrap().file_name())
619            .collect();
620        assert_eq!(names, vec![std::ffi::OsString::from("ghola")]);
621        #[cfg(unix)]
622        {
623            use std::os::unix::fs::PermissionsExt;
624            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
625            assert_eq!(mode & 0o777, 0o755);
626        }
627    }
628
629    #[tokio::test]
630    async fn test_install_zip_on_windows() {
631        let dir = tempfile::tempdir().unwrap();
632        let archive = zip_of(&[("README.md", b"hi"), ("ghola.exe", BINARY)]);
633        let source = release(WINDOWS, archive, None);
634        let outcome = install_latest(&source, WINDOWS, dir.path()).await.unwrap();
635        let path = dir.path().join("ghola.exe");
636        assert!(matches!(outcome, Outcome::Installed { path: p, .. } if p == path));
637        assert_eq!(std::fs::read(&path).unwrap(), BINARY);
638    }
639
640    #[tokio::test]
641    async fn test_a_bad_checksum_installs_nothing() {
642        let dir = tempfile::tempdir().unwrap();
643        let archive = tar_gz(&[("ghola", BINARY)]);
644        let source = release(LINUX, archive, Some("11".repeat(32)));
645        let err = install_latest(&source, LINUX, dir.path())
646            .await
647            .unwrap_err();
648        assert!(err.to_string().contains("SHA-256"), "{err}");
649        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
650    }
651
652    #[tokio::test]
653    async fn test_an_unlisted_archive_installs_nothing() {
654        let dir = tempfile::tempdir().unwrap();
655        let mut source = release(LINUX, tar_gz(&[("ghola", BINARY)]), None);
656        source
657            .files
658            .insert(url(CHECKSUMS), b"00  something-else.tar.gz\n".to_vec());
659        let err = install_latest(&source, LINUX, dir.path())
660            .await
661            .unwrap_err();
662        assert!(err.to_string().contains("does not list"), "{err}");
663        // The archive is never downloaded.
664        let fetched = source.fetched.lock().unwrap();
665        assert!(
666            !fetched.iter().any(|u| u.ends_with(".tar.gz")),
667            "{fetched:?}"
668        );
669    }
670
671    #[tokio::test]
672    async fn test_a_release_without_checksums_installs_nothing() {
673        let dir = tempfile::tempdir().unwrap();
674        let mut source = release(LINUX, tar_gz(&[("ghola", BINARY)]), None);
675        let json = serde_json::json!({
676            "tag_name": "v0.6.2",
677            "assets": [{"name": LINUX.asset_name("0.6.2"),
678                        "browser_download_url": url(&LINUX.asset_name("0.6.2"))}]
679        });
680        source
681            .files
682            .insert(LATEST_RELEASE_URL.into(), json.to_string().into_bytes());
683        let err = install_latest(&source, LINUX, dir.path())
684            .await
685            .unwrap_err();
686        assert!(err.to_string().contains(CHECKSUMS), "{err}");
687        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
688    }
689
690    #[tokio::test]
691    async fn test_no_asset_for_the_target_is_reported() {
692        let dir = tempfile::tempdir().unwrap();
693        let source = release(LINUX, tar_gz(&[("ghola", BINARY)]), None);
694        let mac = Target::from_rust("macos", "x86_64").unwrap();
695        let outcome = install_latest(&source, mac, dir.path()).await.unwrap();
696        assert_eq!(
697            outcome,
698            Outcome::NoAsset {
699                version: "0.6.2".into(),
700                asset: "ghola_0.6.2_darwin_amd64.tar.gz".into()
701            }
702        );
703    }
704
705    #[tokio::test]
706    async fn test_a_binary_below_the_root_is_not_taken() {
707        let dir = tempfile::tempdir().unwrap();
708        let archive = tar_gz(&[("bin/ghola", BINARY)]);
709        let source = release(LINUX, archive, None);
710        let err = install_latest(&source, LINUX, dir.path())
711            .await
712            .unwrap_err();
713        assert!(err.to_string().contains("no ghola"), "{err}");
714    }
715
716    #[test]
717    fn test_locate_prefers_path_then_the_install_dir() {
718        let file = format!("ghola{}", std::env::consts::EXE_SUFFIX);
719        let on_path = tempfile::tempdir().unwrap();
720        let installed = tempfile::tempdir().unwrap();
721        let empty = tempfile::tempdir().unwrap();
722        let path = std::env::join_paths([empty.path(), on_path.path()]).unwrap();
723        assert_eq!(locate(Some(&path), installed.path()), None);
724        std::fs::write(installed.path().join(&file), b"x").unwrap();
725        assert_eq!(
726            locate(Some(&path), installed.path()),
727            Some(installed.path().join(&file))
728        );
729        std::fs::write(on_path.path().join(&file), b"x").unwrap();
730        assert_eq!(
731            locate(Some(&path), installed.path()),
732            Some(on_path.path().join(&file))
733        );
734        assert_eq!(
735            locate(None, installed.path()),
736            Some(installed.path().join(&file))
737        );
738    }
739
740    #[test]
741    fn test_in_path_finds_a_program_in_any_path_folder() {
742        let empty = tempfile::tempdir().unwrap();
743        let with = tempfile::tempdir().unwrap();
744        let program = format!("ghola{}", std::env::consts::EXE_SUFFIX);
745        std::fs::write(with.path().join(&program), b"").unwrap();
746        let path = std::env::join_paths([empty.path(), with.path()]).unwrap();
747        assert_eq!(in_path(&path, "ghola"), Some(with.path().join(&program)));
748        let only_empty = std::env::join_paths([empty.path()]).unwrap();
749        assert_eq!(in_path(&only_empty, "ghola"), None);
750    }
751
752    #[test]
753    fn test_manual_steps_use_the_cmd_package() {
754        let steps = manual_steps().join("\n");
755        assert!(steps.contains("/cmd/ghola@latest"), "{steps}");
756        assert!(steps.contains(RELEASES_PAGE), "{steps}");
757    }
758
759    #[tokio::test]
760    async fn test_github_releases_refuses_other_urls_before_any_request() {
761        let source = GithubReleases::new().unwrap();
762        for url in [
763            "https://example.com/ghola.tar.gz",
764            "http://github.com/robot-accomplice/ghola",
765        ] {
766            let err = source.fetch(url).await.unwrap_err();
767            assert!(err.to_string().contains("not a GitHub HTTPS URL"), "{err}");
768        }
769    }
770
771    /// The allow-list for a local mock server: plain HTTP to loopback.
772    fn loopback(url: &str) -> bool {
773        reqwest::Url::parse(url).is_ok_and(|u| u.host_str() == Some("127.0.0.1"))
774    }
775
776    fn local_source() -> GithubReleases {
777        GithubReleases::with_policy(loopback, false).unwrap()
778    }
779
780    #[tokio::test]
781    async fn test_fetch_returns_the_body() {
782        let mut server = mockito::Server::new_async().await;
783        let mock = server
784            .mock("GET", "/checksums.txt")
785            .with_body("abc  ghola.tar.gz\n")
786            .create_async()
787            .await;
788        let body = local_source()
789            .fetch(&format!("{}/checksums.txt", server.url()))
790            .await
791            .unwrap();
792        assert_eq!(body, b"abc  ghola.tar.gz\n");
793        mock.assert_async().await;
794    }
795
796    #[tokio::test]
797    async fn test_fetch_reports_an_http_error() {
798        let mut server = mockito::Server::new_async().await;
799        server
800            .mock("GET", "/missing")
801            .with_status(404)
802            .create_async()
803            .await;
804        let err = local_source()
805            .fetch(&format!("{}/missing", server.url()))
806            .await
807            .unwrap_err();
808        assert!(err.to_string().contains("HTTP 404"), "{err}");
809    }
810
811    #[tokio::test]
812    async fn test_fetch_follows_an_allowed_redirect() {
813        let mut server = mockito::Server::new_async().await;
814        server
815            .mock("GET", "/download")
816            .with_status(302)
817            .with_header("location", &format!("{}/asset", server.url()))
818            .create_async()
819            .await;
820        let asset = server
821            .mock("GET", "/asset")
822            .with_body("archive")
823            .create_async()
824            .await;
825        let body = local_source()
826            .fetch(&format!("{}/download", server.url()))
827            .await
828            .unwrap();
829        assert_eq!(body, b"archive");
830        asset.assert_async().await;
831    }
832
833    #[tokio::test]
834    async fn test_fetch_refuses_a_redirect_off_the_allow_list() {
835        let mut server = mockito::Server::new_async().await;
836        server
837            .mock("GET", "/download")
838            .with_status(302)
839            .with_header("location", "http://example.invalid/ghola.tar.gz")
840            .create_async()
841            .await;
842        let err = local_source()
843            .fetch(&format!("{}/download", server.url()))
844            .await
845            .unwrap_err();
846        assert!(err.to_string().contains("redirect"), "{err}");
847    }
848
849    #[tokio::test]
850    async fn test_fetch_stops_a_redirect_loop() {
851        let mut server = mockito::Server::new_async().await;
852        let looped = server
853            .mock("GET", "/loop")
854            .with_status(302)
855            .with_header("location", &format!("{}/loop", server.url()))
856            .expect(10)
857            .create_async()
858            .await;
859        let err = local_source()
860            .fetch(&format!("{}/loop", server.url()))
861            .await
862            .unwrap_err();
863        assert!(err.to_string().contains("redirect"), "{err}");
864        looped.assert_async().await;
865    }
866
867    #[tokio::test]
868    async fn test_fetch_reports_an_unreachable_host() {
869        let err = local_source()
870            .fetch("http://127.0.0.1:1/ghola.tar.gz")
871            .await
872            .unwrap_err();
873        assert!(err.to_string().contains("failed"), "{err}");
874    }
875
876    /// Serves one raw HTTP response on a loopback port and returns its URL.
877    async fn serve_once(response: &'static [u8]) -> String {
878        use tokio::io::{AsyncReadExt, AsyncWriteExt};
879        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
880        let addr = listener.local_addr().unwrap();
881        tokio::spawn(async move {
882            let (mut stream, _) = listener.accept().await.unwrap();
883            let mut request = [0u8; 1024];
884            let _ = stream.read(&mut request).await;
885            let _ = stream.write_all(response).await;
886        });
887        format!("http://{addr}/ghola.tar.gz")
888    }
889
890    #[tokio::test]
891    async fn test_fetch_refuses_an_announced_oversize_body() {
892        let url = serve_once(b"HTTP/1.1 200 OK\r\ncontent-length: 200000000\r\n\r\npartial").await;
893        let err = local_source().fetch(&url).await.unwrap_err();
894        assert!(err.to_string().contains("larger than"), "{err}");
895    }
896
897    #[tokio::test]
898    async fn test_fetch_reports_a_truncated_body() {
899        let url = serve_once(b"HTTP/1.1 200 OK\r\ncontent-length: 100\r\n\r\nshort").await;
900        let err = local_source().fetch(&url).await.unwrap_err();
901        assert!(err.to_string().contains("failed"), "{err}");
902    }
903
904    #[tokio::test]
905    async fn test_a_corrupt_archive_installs_nothing() {
906        for target in [LINUX, WINDOWS] {
907            let dir = tempfile::tempdir().unwrap();
908            let source = release(target, b"not an archive".to_vec(), None);
909            let err = install_latest(&source, target, dir.path())
910                .await
911                .unwrap_err();
912            assert!(
913                err.to_string().contains("unreadable ghola archive"),
914                "{err}"
915            );
916            assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
917        }
918    }
919
920    #[tokio::test]
921    async fn test_a_zip_without_ghola_exe_installs_nothing() {
922        let dir = tempfile::tempdir().unwrap();
923        // The Unix name is not the Windows binary.
924        let source = release(WINDOWS, zip_of(&[("ghola", BINARY)]), None);
925        let err = install_latest(&source, WINDOWS, dir.path())
926            .await
927            .unwrap_err();
928        assert!(err.to_string().contains("no ghola.exe"), "{err}");
929        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
930    }
931
932    #[tokio::test]
933    async fn test_a_failed_write_leaves_no_temporary_file() {
934        let dir = tempfile::tempdir().unwrap();
935        // A folder where the binary goes: the rename into place fails.
936        std::fs::create_dir(dir.path().join("ghola")).unwrap();
937        std::fs::write(dir.path().join("ghola").join("keep"), b"x").unwrap();
938        let source = release(LINUX, tar_gz(&[("ghola", BINARY)]), None);
939        let err = install_latest(&source, LINUX, dir.path())
940            .await
941            .unwrap_err();
942        assert!(err.to_string().contains("cannot install ghola"), "{err}");
943        let names: Vec<_> = std::fs::read_dir(dir.path())
944            .unwrap()
945            .map(|e| e.unwrap().file_name())
946            .collect();
947        assert_eq!(names, vec![std::ffi::OsString::from("ghola")]);
948    }
949
950    #[test]
951    fn test_find_go_returns_a_go_program() {
952        if let Some(go) = find_go() {
953            assert!(go.is_file(), "{}", go.display());
954            assert_eq!(go.file_stem(), Some(OsStr::new("go")));
955        }
956    }
957
958    #[test]
959    fn test_go_install_reports_a_missing_go() {
960        let dir = tempfile::tempdir().unwrap();
961        let err = go_install(&dir.path().join("no-go"), &dir.path().join("bin")).unwrap_err();
962        assert!(err.to_string().contains("cannot run"), "{err}");
963    }
964
965    #[test]
966    fn test_go_install_reports_a_folder_it_cannot_create() {
967        let dir = tempfile::tempdir().unwrap();
968        let file = dir.path().join("file");
969        std::fs::write(&file, b"x").unwrap();
970        let err = go_install(&dir.path().join("go"), &file.join("bin")).unwrap_err();
971        assert!(err.to_string().contains("cannot create"), "{err}");
972    }
973
974    /// An executable `sh` script named `name` in `dir`.
975    #[cfg(unix)]
976    fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
977        use std::os::unix::fs::PermissionsExt;
978        let path = dir.join(name);
979        std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
980        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
981        path
982    }
983
984    #[cfg(unix)]
985    #[test]
986    fn test_go_install_builds_the_cmd_package_into_the_folder() {
987        let dir = tempfile::tempdir().unwrap();
988        let check = format!("[ \"$1 $2\" = \"install {GO_PACKAGE}\" ] || exit 9");
989        let go = script(
990            dir.path(),
991            "go",
992            &format!("{check}\nprintf built > \"$GOBIN/ghola\""),
993        );
994        let bin = dir.path().join("bin");
995        let path = go_install(&go, &bin).unwrap();
996        assert_eq!(path, bin.join("ghola"));
997        assert_eq!(std::fs::read(&path).unwrap(), b"built");
998    }
999
1000    #[cfg(unix)]
1001    #[test]
1002    fn test_go_install_reports_a_failed_build() {
1003        let dir = tempfile::tempdir().unwrap();
1004        let go = script(dir.path(), "go", "exit 3");
1005        let err = go_install(&go, &dir.path().join("bin")).unwrap_err();
1006        assert!(err.to_string().contains("failed"), "{err}");
1007        assert!(err.to_string().contains(GO_INSTALL), "{err}");
1008    }
1009
1010    #[cfg(unix)]
1011    #[test]
1012    fn test_go_install_reports_a_build_that_wrote_nothing() {
1013        let dir = tempfile::tempdir().unwrap();
1014        let go = script(dir.path(), "go", "exit 0");
1015        let err = go_install(&go, &dir.path().join("bin")).unwrap_err();
1016        assert!(err.to_string().contains("did not write"), "{err}");
1017    }
1018}