use anyhow::{Context, Result, bail};
use asset_picker::{AssetPicker, Format, detect_platform_from_url};
use serde::Deserialize;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Debug, Clone, Deserialize)]
pub struct Asset {
pub name: String,
#[serde(rename = "browser_download_url")]
pub url: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Release {
#[serde(rename = "tag_name")]
pub tag: String,
pub published_at: Option<String>,
#[serde(default)]
pub prerelease: bool,
#[serde(default)]
pub draft: bool,
#[serde(default)]
pub assets: Vec<Asset>,
}
const PER_PAGE: usize = 100;
const MAX_PAGES: usize = 10;
fn github_token() -> Option<String> {
std::env::var("GITHUB_TOKEN")
.or_else(|_| std::env::var("GH_TOKEN"))
.ok()
.filter(|t| !t.is_empty())
}
fn curl(url: &str, output: Option<&Path>, auth: bool) -> Result<Vec<u8>> {
let mut cfg = String::from(
"silent\nshow-error\nlocation\nfail\nproto = \"=https\"\nproto-redir = \"=https\"\n",
);
cfg.push_str("header = \"User-Agent: tak\"\n");
if auth && let Some(token) = github_token() {
if token.contains(['"', '\n', '\r']) {
bail!("refusing to use a token containing quotes or newlines");
}
cfg.push_str(&format!("header = \"Authorization: Bearer {token}\"\n"));
}
if !url.starts_with("https://") {
bail!("refusing a non-https URL: {url}");
}
let mut cmd = Command::new("curl");
cmd.arg("--config").arg("-");
if let Some(p) = output {
cmd.arg("-o").arg(p);
}
cmd.arg("--").arg(url);
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to run curl — is it installed?")?;
child
.stdin
.take()
.context("curl stdin unavailable")?
.write_all(cfg.as_bytes())?;
let out = child.wait_with_output()?;
if !out.status.success() {
bail!(
"curl failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(out.stdout)
}
pub fn list_releases(repo: &str, limit: usize) -> Result<Vec<Release>> {
let mut out: Vec<Release> = Vec::new();
for page in 1..=MAX_PAGES {
let url =
format!("https://api.github.com/repos/{repo}/releases?per_page={PER_PAGE}&page={page}");
let body = curl(&url, None, true)?;
let batch: Vec<Release> =
serde_json::from_slice(&body).context("could not parse the GitHub releases API")?;
let exhausted = batch.len() < PER_PAGE;
out.extend(
batch
.into_iter()
.filter(|r| !r.prerelease && !r.draft && !r.assets.is_empty()),
);
if out.len() >= limit {
break;
}
if exhausted {
return Ok(finish(out, limit));
}
if page == MAX_PAGES {
eprintln!(
"warning: stopped after {MAX_PAGES} pages with {} of {limit} releases; \
older releases exist but were not fetched",
out.len()
);
}
}
Ok(finish(out, limit))
}
fn finish(mut v: Vec<Release>, limit: usize) -> Vec<Release> {
v.truncate(limit);
v
}
fn host_is_recognised() -> bool {
matches!(std::env::consts::OS, "linux" | "macos" | "windows")
&& matches!(std::env::consts::ARCH, "x86_64" | "aarch64")
}
fn host_libc() -> Option<String> {
cfg!(target_env = "musl").then(|| "musl".to_string())
}
fn is_usable_subject(name: &str) -> bool {
let n = name.to_lowercase();
if n.ends_with(".vsix") {
return false;
}
const SIDECAR: [&str; 9] = [
".sha256",
".sha512",
".sha1",
".md5",
".asc",
".sig",
".pem",
".sbom",
".intoto.jsonl",
];
if SIDECAR.iter().any(|e| n.ends_with(e)) {
return false;
}
let stem = n.rsplit('/').next().unwrap_or(&n);
if ["source", "sources", "src"].iter().any(|t| {
stem.split(|c: char| !c.is_ascii_alphanumeric())
.any(|w| w == *t)
}) {
return false;
}
match Format::from_file_name(&n) {
Format::Raw => {
let base = n.rsplit('/').next().unwrap_or(&n);
base.ends_with(".exe") || !has_file_extension(base)
}
Format::Tar => !TAR_NEEDS_RARE_CODEC.iter().any(|e| n.ends_with(e)),
Format::Zip => true,
_ => false,
}
}
fn has_file_extension(base: &str) -> bool {
match base.rsplit_once('.') {
None => false,
Some((_, ext)) => {
!ext.is_empty()
&& ext.len() <= 6
&& ext.chars().all(|c| c.is_ascii_alphanumeric())
&& !ext.chars().all(|c| c.is_ascii_digit())
}
}
}
const TAR_NEEDS_RARE_CODEC: [&str; 4] = [".tar.br", ".tbr", ".tar.lz4", ".tlz4"];
pub fn pick_asset(assets: &[Asset]) -> Option<&Asset> {
if !host_is_recognised() {
return None;
}
let usable: Vec<String> = assets
.iter()
.map(|a| a.name.clone())
.filter(|n| is_usable_subject(n))
.collect();
let declares_platform = |n: &String| detect_platform_from_url(n).is_some();
let names: Vec<String> = if usable.iter().any(declares_platform) {
usable
.into_iter()
.filter(|n| declares_platform(n))
.collect()
} else {
usable
};
let picked = AssetPicker::with_libc(
std::env::consts::OS.to_string(),
std::env::consts::ARCH.to_string(),
host_libc(),
)
.with_no_app(true)
.pick_best_asset(&names)?;
assets.iter().find(|a| a.name == picked)
}
fn run(cmd: &mut Command, what: &str) -> Result<()> {
let out = cmd
.output()
.with_context(|| format!("failed to run {what}"))?;
if !out.status.success() {
bail!(
"{what} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
pub fn fetch_binary(asset: &Asset, bin_name: &str, dir: &Path) -> Result<PathBuf> {
std::fs::remove_dir_all(dir).ok();
std::fs::create_dir_all(dir)?;
let archive = dir.join(safe_component(&asset.name)?);
curl(&asset.url, Some(&archive), true)?;
match Format::from_file_name(&asset.name) {
Format::Tar => run(
Command::new("tar").args([
"-xf",
&archive.to_string_lossy(),
"-C",
&dir.to_string_lossy(),
]),
"tar",
)?,
Format::Zip => run(
Command::new("unzip").args([
"-oq",
&archive.to_string_lossy(),
"-d",
&dir.to_string_lossy(),
]),
"unzip",
)?,
Format::Raw => {
make_executable(&archive)?;
return Ok(archive);
}
other => bail!("cannot unpack {} ({other:?})", asset.name),
}
let found = find_binary(dir, bin_name)
.with_context(|| format!("no `{bin_name}` inside {}", asset.name))?;
make_executable(&found)?;
Ok(found)
}
fn safe_component(name: &str) -> Result<String> {
let base = Path::new(name)
.file_name()
.and_then(|s| s.to_str())
.filter(|s| !s.is_empty() && *s != "." && *s != "..")
.with_context(|| format!("unusable name from the API: {name:?}"))?;
Ok(base.to_string())
}
pub fn release_dir_name(index: usize, tag: &str) -> String {
format!("{index:04}-{}", safe_dir_name(tag))
}
fn safe_dir_name(tag: &str) -> String {
let s: String = tag
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
c
} else {
'_'
}
})
.collect();
let trimmed = s.trim_matches('.');
if trimmed.is_empty() {
"release".to_string()
} else {
trimmed.to_string()
}
}
fn make_executable(p: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(p)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(p, perms)?;
}
#[cfg(not(unix))]
let _ = p;
Ok(())
}
fn binary_candidates(name: &str) -> Vec<String> {
let mut v = vec![name.to_string()];
if cfg!(windows) && !name.ends_with(".exe") {
v.push(format!("{name}.exe"));
}
v
}
fn find_binary(dir: &Path, name: &str) -> Option<PathBuf> {
fn walk(dir: &Path, names: &[String], depth: u32) -> Option<PathBuf> {
if depth > 3 {
return None;
}
let entries = std::fs::read_dir(dir).ok()?;
let mut dirs = Vec::new();
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
dirs.push(p);
} else if p
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|f| names.iter().any(|n| n == f))
{
return Some(p);
}
}
dirs.into_iter().find_map(|d| walk(&d, names, depth + 1))
}
walk(dir, &binary_candidates(name), 0)
}
pub fn in_git_repo() -> bool {
Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn tag_commit(tag: &str) -> Option<String> {
let out = Command::new("git")
.args(["rev-parse", &format!("{tag}^{{commit}}")])
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub fn version_of(tag: &str) -> &str {
tag.strip_prefix('v').unwrap_or(tag)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pick_asset_chooses_the_best_candidate() {
let assets: Vec<Asset> = [
"tool-x86_64-apple-darwin.tar.gz",
"tool-x86_64-unknown-linux-gnu.tar.gz",
"tool-x86_64-unknown-linux-musl.tar.gz",
"tool-x86_64-unknown-linux-musl.tar.gz.sha256",
]
.iter()
.map(|n| Asset {
name: n.to_string(),
url: format!("https://example.invalid/{n}"),
})
.collect();
if std::env::consts::OS == "linux" && std::env::consts::ARCH == "x86_64" {
let got = pick_asset(&assets).expect("something should match");
let want = if cfg!(target_env = "musl") {
"tool-x86_64-unknown-linux-musl.tar.gz"
} else {
"tool-x86_64-unknown-linux-gnu.tar.gz"
};
assert_eq!(got.name, want);
}
}
#[test]
fn version_strips_only_the_v_prefix() {
assert_eq!(version_of("v1.2.3"), "1.2.3");
assert_eq!(version_of("2024.01.15"), "2024.01.15");
assert_eq!(version_of("nightly"), "nightly");
assert_eq!(version_of("lts-iron"), "lts-iron");
}
#[test]
fn windows_archives_may_carry_an_exe_suffix() {
let c = binary_candidates("mycli");
assert!(c.contains(&"mycli".to_string()));
if cfg!(windows) {
assert!(c.contains(&"mycli.exe".to_string()));
}
assert_eq!(binary_candidates("mycli.exe").len(), 1);
}
#[test]
fn source_archives_are_not_benchmark_subjects() {
assert!(!is_usable_subject("tool-1.0-source.tar.gz"));
assert!(!is_usable_subject("tool-src.tar.gz"));
assert!(!is_usable_subject("sources.zip"));
assert!(is_usable_subject("resource-cli-linux-x86_64.tar.gz"));
assert!(is_usable_subject("srcery-linux-amd64.tar.gz"));
}
#[test]
fn sidecars_are_never_subjects() {
for n in [
"tool-x86_64-unknown-linux-gnu.tar.gz.sha256",
"tool-x86_64-unknown-linux-gnu.tar.gz.asc",
"tool.sig",
"tool.intoto.jsonl",
] {
assert!(!is_usable_subject(n), "{n} should be rejected");
}
}
#[test]
fn vsix_is_openable_but_never_a_subject() {
assert_eq!(Format::from_file_name("tool.vsix"), Format::Zip);
assert!(!is_usable_subject("tool-linux-x86_64.vsix"));
}
#[test]
fn platform_tagged_metadata_is_not_a_subject() {
for n in [
"tool-linux-x86_64.json",
"tool-linux-x86_64.yaml",
"tool-linux-x86_64.run",
"tool-linux-x86_64.txt",
] {
assert!(!is_usable_subject(n), "{n} should be rejected");
}
assert!(is_usable_subject("tool-linux-x86_64"));
assert!(is_usable_subject("tool.exe"));
}
#[test]
fn a_versioned_bare_binary_is_still_a_subject() {
assert!(is_usable_subject("tool-v1.2.3-linux-x86_64"));
assert!(is_usable_subject("tool-1.2.3"));
assert!(is_usable_subject("tool-linux-amd64-2024.01.15"));
}
#[test]
fn extension_detection_distinguishes_versions_from_suffixes() {
assert!(has_file_extension("tool.json"));
assert!(has_file_extension("tool.yaml"));
assert!(has_file_extension("tool.exe"));
assert!(!has_file_extension("tool-1.2.3"));
assert!(!has_file_extension("tool-v1.2.3-linux-x86_64"));
assert!(!has_file_extension("tool"));
}
#[test]
fn tar_codecs_the_host_may_lack_are_skipped() {
assert!(is_usable_subject("tool-linux-x86_64.tar.gz"));
assert!(is_usable_subject("tool-linux-x86_64.tar.zst"));
assert!(!is_usable_subject("tool-linux-x86_64.tar.br"));
assert!(!is_usable_subject("tool-linux-x86_64.tar.lz4"));
}
#[test]
fn only_openable_formats_are_subjects() {
assert!(is_usable_subject("tool-linux-x86_64.tar.gz"));
assert!(is_usable_subject("tool-linux-x86_64.zip"));
assert!(is_usable_subject("tool"));
assert!(is_usable_subject("tool.exe"));
assert!(!is_usable_subject("tool-linux-x86_64.7z"));
assert!(!is_usable_subject("tool-linux-x86_64.gz"));
assert!(!is_usable_subject("tool.rar"));
}
#[test]
fn a_platform_asset_beats_a_source_drop() {
let assets: Vec<Asset> = [
"tool-1.0-source.tar.gz",
"tool-x86_64-unknown-linux-gnu.tar.gz",
]
.iter()
.map(|n| Asset {
name: n.to_string(),
url: format!("https://example.invalid/{n}"),
})
.collect();
if std::env::consts::OS == "linux" && std::env::consts::ARCH == "x86_64" {
assert_eq!(
pick_asset(&assets).map(|a| a.name.as_str()),
Some("tool-x86_64-unknown-linux-gnu.tar.gz")
);
}
}
#[test]
fn unrecognised_hosts_are_refused() {
let recognised = matches!(std::env::consts::OS, "linux" | "macos" | "windows")
&& matches!(std::env::consts::ARCH, "x86_64" | "aarch64");
assert_eq!(host_is_recognised(), recognised);
if !host_is_recognised() {
let assets = vec![Asset {
name: "tool-x86_64-unknown-linux-gnu.tar.gz".to_string(),
url: "https://example.invalid/x".to_string(),
}];
assert!(pick_asset(&assets).is_none());
}
}
#[test]
fn an_unlabelled_archive_never_substitutes_for_a_platform_build() {
let assets: Vec<Asset> = ["tool-x86_64-unknown-linux-gnu.tar.gz", "tool.tar.gz"]
.iter()
.map(|n| Asset {
name: n.to_string(),
url: format!("https://example.invalid/{n}"),
})
.collect();
if std::env::consts::OS == "linux" && std::env::consts::ARCH == "x86_64" {
assert_eq!(
pick_asset(&assets).map(|a| a.name.as_str()),
Some("tool-x86_64-unknown-linux-gnu.tar.gz")
);
}
if std::env::consts::OS == "macos" {
assert!(pick_asset(&assets).is_none());
}
}
#[test]
fn a_wholly_unlabelled_release_is_still_usable() {
let assets = vec![Asset {
name: "tool.tar.gz".to_string(),
url: "https://example.invalid/tool.tar.gz".to_string(),
}];
assert!(pick_asset(&assets).is_some());
}
#[test]
fn a_release_of_only_source_yields_nothing() {
let assets = vec![Asset {
name: "tool-1.0-source.tar.gz".to_string(),
url: "https://example.invalid/x".to_string(),
}];
assert!(pick_asset(&assets).is_none());
}
#[test]
fn release_dirs_never_collide() {
assert_ne!(
release_dir_name(0, "v1/0"),
release_dir_name(1, "v1_0"),
"distinct releases must not share a work directory"
);
assert_eq!(release_dir_name(7, "v1.2.3"), "0007-v1.2.3");
assert!(!release_dir_name(0, "release/1.0").contains('/'));
assert!(!release_dir_name(0, "...").is_empty());
}
#[test]
fn finds_a_nested_binary() {
let dir = std::env::temp_dir().join(format!("tak-find-{}", std::process::id()));
let nested = dir.join("tool-1.0").join("bin");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("mycli"), b"#!/bin/sh\n").unwrap();
assert_eq!(find_binary(&dir, "mycli"), Some(nested.join("mycli")));
assert_eq!(find_binary(&dir, "absent"), None);
std::fs::remove_dir_all(&dir).ok();
}
}