1use 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
35pub const LATEST_RELEASE_URL: &str =
37 "https://api.github.com/repos/robot-accomplice/ghola/releases/latest";
38
39pub const RELEASES_PAGE: &str = "https://github.com/robot-accomplice/ghola/releases";
41
42pub const GO_PACKAGE: &str = "github.com/robot-accomplice/ghola/cmd/ghola@latest";
44
45pub const GO_INSTALL: &str = "go install github.com/robot-accomplice/ghola/cmd/ghola@latest";
47
48const ALLOWED_HOSTS: &[&str] = &[
51 "api.github.com",
52 "github.com",
53 "objects.githubusercontent.com",
54 "release-assets.githubusercontent.com",
55];
56
57const MAX_DOWNLOAD_BYTES: usize = 100 * 1024 * 1024;
59
60const MAX_BINARY_BYTES: u64 = 200 * 1024 * 1024;
62
63const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120);
65
66const CHECKSUMS: &str = "checksums.txt";
68
69#[async_trait]
71pub trait ReleaseSource: Send + Sync {
72 async fn fetch(&self, url: &str) -> Result<Vec<u8>, ScopeError>;
74}
75
76pub struct GithubReleases {
79 client: reqwest::Client,
80 allowed: fn(&str) -> bool,
82}
83
84impl GithubReleases {
85 pub fn new() -> Result<Self, ScopeError> {
91 Self::with_policy(allowed_url, true)
92 }
93
94 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
163pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub struct Target {
173 pub os: &'static str,
174 pub arch: &'static str,
175}
176
177impl Target {
178 pub fn current() -> Option<Self> {
180 Self::from_rust(std::env::consts::OS, std::env::consts::ARCH)
181 }
182
183 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum Outcome {
231 Installed { path: PathBuf, version: String },
233 NoAsset { version: String, asset: String },
235}
236
237pub 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
292pub 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
305fn 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
358fn 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
387pub fn install_dir() -> PathBuf {
389 crate::paths::home().join("bin")
390}
391
392pub fn find() -> Option<PathBuf> {
394 locate(std::env::var_os("PATH").as_deref(), &install_dir())
395}
396
397pub 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
405pub 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
415pub fn find_go() -> Option<PathBuf> {
417 std::env::var_os("PATH").and_then(|p| in_path(&p, "go"))
418}
419
420pub 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
450pub 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#[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 pub const BINARY: &[u8] = b"\x7fELF fake ghola";
476
477 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 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 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 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 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 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 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 #[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}