1use anyhow::{Context, Result, bail};
16use asset_picker::{AssetPicker, Format, detect_platform_from_url};
17use serde::Deserialize;
18use std::io::Write;
19use std::path::{Path, PathBuf};
20use std::process::{Command, Stdio};
21
22#[derive(Debug, Clone, Deserialize)]
23pub struct Asset {
24 pub name: String,
25 #[serde(rename = "browser_download_url")]
26 pub url: String,
27}
28
29#[derive(Debug, Clone, Deserialize)]
30pub struct Release {
31 #[serde(rename = "tag_name")]
32 pub tag: String,
33 pub published_at: Option<String>,
34 #[serde(default)]
35 pub prerelease: bool,
36 #[serde(default)]
39 pub draft: bool,
40 #[serde(default)]
41 pub assets: Vec<Asset>,
42}
43
44const PER_PAGE: usize = 100;
46const MAX_PAGES: usize = 10;
48
49fn github_token() -> Option<String> {
50 std::env::var("GITHUB_TOKEN")
51 .or_else(|_| std::env::var("GH_TOKEN"))
52 .ok()
53 .filter(|t| !t.is_empty())
54}
55
56fn curl(url: &str, output: Option<&Path>, auth: bool) -> Result<Vec<u8>> {
66 let mut cfg = String::from(
71 "silent\nshow-error\nlocation\nfail\nproto = \"=https\"\nproto-redir = \"=https\"\n",
72 );
73 cfg.push_str("header = \"User-Agent: tak\"\n");
74 if auth && let Some(token) = github_token() {
75 if token.contains(['"', '\n', '\r']) {
79 bail!("refusing to use a token containing quotes or newlines");
80 }
81 cfg.push_str(&format!("header = \"Authorization: Bearer {token}\"\n"));
82 }
83
84 if !url.starts_with("https://") {
87 bail!("refusing a non-https URL: {url}");
88 }
89
90 let mut cmd = Command::new("curl");
91 cmd.arg("--config").arg("-");
92 if let Some(p) = output {
93 cmd.arg("-o").arg(p);
94 }
95 cmd.arg("--").arg(url);
96
97 let mut child = cmd
98 .stdin(Stdio::piped())
99 .stdout(Stdio::piped())
100 .stderr(Stdio::piped())
101 .spawn()
102 .context("failed to run curl — is it installed?")?;
103 child
104 .stdin
105 .take()
106 .context("curl stdin unavailable")?
107 .write_all(cfg.as_bytes())?;
108
109 let out = child.wait_with_output()?;
110 if !out.status.success() {
111 bail!(
112 "curl failed: {}",
113 String::from_utf8_lossy(&out.stderr).trim()
114 );
115 }
116 Ok(out.stdout)
117}
118
119pub fn list_releases(repo: &str, limit: usize) -> Result<Vec<Release>> {
128 let mut out: Vec<Release> = Vec::new();
129
130 for page in 1..=MAX_PAGES {
131 let url =
132 format!("https://api.github.com/repos/{repo}/releases?per_page={PER_PAGE}&page={page}");
133 let body = curl(&url, None, true)?;
134 let batch: Vec<Release> =
135 serde_json::from_slice(&body).context("could not parse the GitHub releases API")?;
136 let exhausted = batch.len() < PER_PAGE;
137
138 out.extend(
139 batch
140 .into_iter()
141 .filter(|r| !r.prerelease && !r.draft && !r.assets.is_empty()),
142 );
143
144 if out.len() >= limit {
145 break;
146 }
147 if exhausted {
148 return Ok(finish(out, limit));
149 }
150 if page == MAX_PAGES {
151 eprintln!(
152 "warning: stopped after {MAX_PAGES} pages with {} of {limit} releases; \
153 older releases exist but were not fetched",
154 out.len()
155 );
156 }
157 }
158
159 Ok(finish(out, limit))
160}
161
162fn finish(mut v: Vec<Release>, limit: usize) -> Vec<Release> {
163 v.truncate(limit);
164 v
165}
166
167fn host_is_recognised() -> bool {
173 matches!(std::env::consts::OS, "linux" | "macos" | "windows")
174 && matches!(std::env::consts::ARCH, "x86_64" | "aarch64")
175}
176
177fn host_libc() -> Option<String> {
188 cfg!(target_env = "musl").then(|| "musl".to_string())
189}
190
191fn is_usable_subject(name: &str) -> bool {
199 let n = name.to_lowercase();
200
201 if n.ends_with(".vsix") {
205 return false;
206 }
207
208 const SIDECAR: [&str; 9] = [
214 ".sha256",
215 ".sha512",
216 ".sha1",
217 ".md5",
218 ".asc",
219 ".sig",
220 ".pem",
221 ".sbom",
222 ".intoto.jsonl",
223 ];
224 if SIDECAR.iter().any(|e| n.ends_with(e)) {
225 return false;
226 }
227
228 let stem = n.rsplit('/').next().unwrap_or(&n);
230 if ["source", "sources", "src"].iter().any(|t| {
231 stem.split(|c: char| !c.is_ascii_alphanumeric())
232 .any(|w| w == *t)
233 }) {
234 return false;
235 }
236
237 match Format::from_file_name(&n) {
241 Format::Raw => {
248 let base = n.rsplit('/').next().unwrap_or(&n);
249 base.ends_with(".exe") || !has_file_extension(base)
250 }
251 Format::Tar => !TAR_NEEDS_RARE_CODEC.iter().any(|e| n.ends_with(e)),
256 Format::Zip => true,
257 _ => false,
258 }
259}
260
261fn has_file_extension(base: &str) -> bool {
269 match base.rsplit_once('.') {
270 None => false,
271 Some((_, ext)) => {
272 !ext.is_empty()
273 && ext.len() <= 6
274 && ext.chars().all(|c| c.is_ascii_alphanumeric())
275 && !ext.chars().all(|c| c.is_ascii_digit())
276 }
277 }
278}
279
280const TAR_NEEDS_RARE_CODEC: [&str; 4] = [".tar.br", ".tbr", ".tar.lz4", ".tlz4"];
282
283pub fn pick_asset(assets: &[Asset]) -> Option<&Asset> {
284 if !host_is_recognised() {
290 return None;
291 }
292
293 let usable: Vec<String> = assets
294 .iter()
295 .map(|a| a.name.clone())
296 .filter(|n| is_usable_subject(n))
297 .collect();
298
299 let declares_platform = |n: &String| detect_platform_from_url(n).is_some();
310 let names: Vec<String> = if usable.iter().any(declares_platform) {
311 usable
312 .into_iter()
313 .filter(|n| declares_platform(n))
314 .collect()
315 } else {
316 usable
317 };
318 let picked = AssetPicker::with_libc(
319 std::env::consts::OS.to_string(),
320 std::env::consts::ARCH.to_string(),
321 host_libc(),
322 )
323 .with_no_app(true)
325 .pick_best_asset(&names)?;
326
327 assets.iter().find(|a| a.name == picked)
328}
329
330fn run(cmd: &mut Command, what: &str) -> Result<()> {
331 let out = cmd
332 .output()
333 .with_context(|| format!("failed to run {what}"))?;
334 if !out.status.success() {
335 bail!(
336 "{what} failed: {}",
337 String::from_utf8_lossy(&out.stderr).trim()
338 );
339 }
340 Ok(())
341}
342
343pub fn fetch_binary(asset: &Asset, bin_name: &str, dir: &Path) -> Result<PathBuf> {
350 std::fs::remove_dir_all(dir).ok();
354 std::fs::create_dir_all(dir)?;
355 let archive = dir.join(safe_component(&asset.name)?);
358
359 curl(&asset.url, Some(&archive), true)?;
360
361 match Format::from_file_name(&asset.name) {
365 Format::Tar => run(
367 Command::new("tar").args([
368 "-xf",
369 &archive.to_string_lossy(),
370 "-C",
371 &dir.to_string_lossy(),
372 ]),
373 "tar",
374 )?,
375 Format::Zip => run(
376 Command::new("unzip").args([
377 "-oq",
378 &archive.to_string_lossy(),
379 "-d",
380 &dir.to_string_lossy(),
381 ]),
382 "unzip",
383 )?,
384 Format::Raw => {
386 make_executable(&archive)?;
387 return Ok(archive);
388 }
389 other => bail!("cannot unpack {} ({other:?})", asset.name),
394 }
395
396 let found = find_binary(dir, bin_name)
397 .with_context(|| format!("no `{bin_name}` inside {}", asset.name))?;
398 make_executable(&found)?;
399 Ok(found)
400}
401
402fn safe_component(name: &str) -> Result<String> {
407 let base = Path::new(name)
408 .file_name()
409 .and_then(|s| s.to_str())
410 .filter(|s| !s.is_empty() && *s != "." && *s != "..")
411 .with_context(|| format!("unusable name from the API: {name:?}"))?;
412 Ok(base.to_string())
413}
414
415pub fn release_dir_name(index: usize, tag: &str) -> String {
421 format!("{index:04}-{}", safe_dir_name(tag))
422}
423
424fn safe_dir_name(tag: &str) -> String {
429 let s: String = tag
430 .chars()
431 .map(|c| {
432 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
433 c
434 } else {
435 '_'
436 }
437 })
438 .collect();
439 let trimmed = s.trim_matches('.');
440 if trimmed.is_empty() {
441 "release".to_string()
442 } else {
443 trimmed.to_string()
444 }
445}
446
447fn make_executable(p: &Path) -> Result<()> {
448 #[cfg(unix)]
449 {
450 use std::os::unix::fs::PermissionsExt;
451 let mut perms = std::fs::metadata(p)?.permissions();
452 perms.set_mode(0o755);
453 std::fs::set_permissions(p, perms)?;
454 }
455 #[cfg(not(unix))]
456 let _ = p;
457 Ok(())
458}
459
460fn binary_candidates(name: &str) -> Vec<String> {
466 let mut v = vec![name.to_string()];
467 if cfg!(windows) && !name.ends_with(".exe") {
468 v.push(format!("{name}.exe"));
469 }
470 v
471}
472
473fn find_binary(dir: &Path, name: &str) -> Option<PathBuf> {
476 fn walk(dir: &Path, names: &[String], depth: u32) -> Option<PathBuf> {
477 if depth > 3 {
478 return None;
479 }
480 let entries = std::fs::read_dir(dir).ok()?;
481 let mut dirs = Vec::new();
482 for e in entries.flatten() {
483 let p = e.path();
484 if p.is_dir() {
485 dirs.push(p);
486 } else if p
487 .file_name()
488 .and_then(|s| s.to_str())
489 .is_some_and(|f| names.iter().any(|n| n == f))
490 {
491 return Some(p);
492 }
493 }
494 dirs.into_iter().find_map(|d| walk(&d, names, depth + 1))
495 }
496 walk(dir, &binary_candidates(name), 0)
497}
498
499pub fn in_git_repo() -> bool {
504 Command::new("git")
505 .args(["rev-parse", "--is-inside-work-tree"])
506 .stdout(Stdio::null())
507 .stderr(Stdio::null())
508 .status()
509 .map(|s| s.success())
510 .unwrap_or(false)
511}
512
513pub fn tag_commit(tag: &str) -> Option<String> {
517 let out = Command::new("git")
518 .args(["rev-parse", &format!("{tag}^{{commit}}")])
519 .output()
520 .ok()?;
521 out.status
522 .success()
523 .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
524}
525
526pub fn version_of(tag: &str) -> &str {
529 tag.strip_prefix('v').unwrap_or(tag)
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
537 fn pick_asset_chooses_the_best_candidate() {
538 let assets: Vec<Asset> = [
539 "tool-x86_64-apple-darwin.tar.gz",
540 "tool-x86_64-unknown-linux-gnu.tar.gz",
541 "tool-x86_64-unknown-linux-musl.tar.gz",
542 "tool-x86_64-unknown-linux-musl.tar.gz.sha256",
543 ]
544 .iter()
545 .map(|n| Asset {
546 name: n.to_string(),
547 url: format!("https://example.invalid/{n}"),
548 })
549 .collect();
550
551 if std::env::consts::OS == "linux" && std::env::consts::ARCH == "x86_64" {
553 let got = pick_asset(&assets).expect("something should match");
554 let want = if cfg!(target_env = "musl") {
559 "tool-x86_64-unknown-linux-musl.tar.gz"
560 } else {
561 "tool-x86_64-unknown-linux-gnu.tar.gz"
562 };
563 assert_eq!(got.name, want);
564 }
565 }
566
567 #[test]
568 fn version_strips_only_the_v_prefix() {
569 assert_eq!(version_of("v1.2.3"), "1.2.3");
570 assert_eq!(version_of("2024.01.15"), "2024.01.15");
571 assert_eq!(version_of("nightly"), "nightly");
573 assert_eq!(version_of("lts-iron"), "lts-iron");
574 }
575
576 #[test]
577 fn windows_archives_may_carry_an_exe_suffix() {
578 let c = binary_candidates("mycli");
579 assert!(c.contains(&"mycli".to_string()));
580 if cfg!(windows) {
581 assert!(c.contains(&"mycli.exe".to_string()));
582 }
583 assert_eq!(binary_candidates("mycli.exe").len(), 1);
585 }
586
587 #[test]
588 fn source_archives_are_not_benchmark_subjects() {
589 assert!(!is_usable_subject("tool-1.0-source.tar.gz"));
592 assert!(!is_usable_subject("tool-src.tar.gz"));
593 assert!(!is_usable_subject("sources.zip"));
594 assert!(is_usable_subject("resource-cli-linux-x86_64.tar.gz"));
596 assert!(is_usable_subject("srcery-linux-amd64.tar.gz"));
597 }
598
599 #[test]
600 fn sidecars_are_never_subjects() {
601 for n in [
604 "tool-x86_64-unknown-linux-gnu.tar.gz.sha256",
605 "tool-x86_64-unknown-linux-gnu.tar.gz.asc",
606 "tool.sig",
607 "tool.intoto.jsonl",
608 ] {
609 assert!(!is_usable_subject(n), "{n} should be rejected");
610 }
611 }
612
613 #[test]
616 fn vsix_is_openable_but_never_a_subject() {
617 assert_eq!(Format::from_file_name("tool.vsix"), Format::Zip);
618 assert!(!is_usable_subject("tool-linux-x86_64.vsix"));
619 }
620
621 #[test]
624 fn platform_tagged_metadata_is_not_a_subject() {
625 for n in [
626 "tool-linux-x86_64.json",
627 "tool-linux-x86_64.yaml",
628 "tool-linux-x86_64.run",
629 "tool-linux-x86_64.txt",
630 ] {
631 assert!(!is_usable_subject(n), "{n} should be rejected");
632 }
633 assert!(is_usable_subject("tool-linux-x86_64"));
635 assert!(is_usable_subject("tool.exe"));
636 }
637
638 #[test]
641 fn a_versioned_bare_binary_is_still_a_subject() {
642 assert!(is_usable_subject("tool-v1.2.3-linux-x86_64"));
643 assert!(is_usable_subject("tool-1.2.3"));
644 assert!(is_usable_subject("tool-linux-amd64-2024.01.15"));
645 }
646
647 #[test]
648 fn extension_detection_distinguishes_versions_from_suffixes() {
649 assert!(has_file_extension("tool.json"));
650 assert!(has_file_extension("tool.yaml"));
651 assert!(has_file_extension("tool.exe"));
652 assert!(!has_file_extension("tool-1.2.3"));
654 assert!(!has_file_extension("tool-v1.2.3-linux-x86_64"));
655 assert!(!has_file_extension("tool"));
656 }
657
658 #[test]
659 fn tar_codecs_the_host_may_lack_are_skipped() {
660 assert!(is_usable_subject("tool-linux-x86_64.tar.gz"));
661 assert!(is_usable_subject("tool-linux-x86_64.tar.zst"));
662 assert!(!is_usable_subject("tool-linux-x86_64.tar.br"));
665 assert!(!is_usable_subject("tool-linux-x86_64.tar.lz4"));
666 }
667
668 #[test]
669 fn only_openable_formats_are_subjects() {
670 assert!(is_usable_subject("tool-linux-x86_64.tar.gz"));
671 assert!(is_usable_subject("tool-linux-x86_64.zip"));
672 assert!(is_usable_subject("tool"));
673 assert!(is_usable_subject("tool.exe"));
674 assert!(!is_usable_subject("tool-linux-x86_64.7z"));
676 assert!(!is_usable_subject("tool-linux-x86_64.gz"));
677 assert!(!is_usable_subject("tool.rar"));
678 }
679
680 #[test]
681 fn a_platform_asset_beats_a_source_drop() {
682 let assets: Vec<Asset> = [
683 "tool-1.0-source.tar.gz",
684 "tool-x86_64-unknown-linux-gnu.tar.gz",
685 ]
686 .iter()
687 .map(|n| Asset {
688 name: n.to_string(),
689 url: format!("https://example.invalid/{n}"),
690 })
691 .collect();
692 if std::env::consts::OS == "linux" && std::env::consts::ARCH == "x86_64" {
693 assert_eq!(
694 pick_asset(&assets).map(|a| a.name.as_str()),
695 Some("tool-x86_64-unknown-linux-gnu.tar.gz")
696 );
697 }
698 }
699
700 #[test]
703 fn unrecognised_hosts_are_refused() {
704 let recognised = matches!(std::env::consts::OS, "linux" | "macos" | "windows")
705 && matches!(std::env::consts::ARCH, "x86_64" | "aarch64");
706 assert_eq!(host_is_recognised(), recognised);
707
708 if !host_is_recognised() {
709 let assets = vec![Asset {
710 name: "tool-x86_64-unknown-linux-gnu.tar.gz".to_string(),
711 url: "https://example.invalid/x".to_string(),
712 }];
713 assert!(pick_asset(&assets).is_none());
714 }
715 }
716
717 #[test]
720 fn an_unlabelled_archive_never_substitutes_for_a_platform_build() {
721 let assets: Vec<Asset> = ["tool-x86_64-unknown-linux-gnu.tar.gz", "tool.tar.gz"]
722 .iter()
723 .map(|n| Asset {
724 name: n.to_string(),
725 url: format!("https://example.invalid/{n}"),
726 })
727 .collect();
728
729 if std::env::consts::OS == "linux" && std::env::consts::ARCH == "x86_64" {
731 assert_eq!(
732 pick_asset(&assets).map(|a| a.name.as_str()),
733 Some("tool-x86_64-unknown-linux-gnu.tar.gz")
734 );
735 }
736 if std::env::consts::OS == "macos" {
739 assert!(pick_asset(&assets).is_none());
740 }
741 }
742
743 #[test]
746 fn a_wholly_unlabelled_release_is_still_usable() {
747 let assets = vec![Asset {
748 name: "tool.tar.gz".to_string(),
749 url: "https://example.invalid/tool.tar.gz".to_string(),
750 }];
751 assert!(pick_asset(&assets).is_some());
752 }
753
754 #[test]
755 fn a_release_of_only_source_yields_nothing() {
756 let assets = vec![Asset {
757 name: "tool-1.0-source.tar.gz".to_string(),
758 url: "https://example.invalid/x".to_string(),
759 }];
760 assert!(pick_asset(&assets).is_none());
761 }
762
763 #[test]
764 fn release_dirs_never_collide() {
765 assert_ne!(
767 release_dir_name(0, "v1/0"),
768 release_dir_name(1, "v1_0"),
769 "distinct releases must not share a work directory"
770 );
771 assert_eq!(release_dir_name(7, "v1.2.3"), "0007-v1.2.3");
772 assert!(!release_dir_name(0, "release/1.0").contains('/'));
774 assert!(!release_dir_name(0, "...").is_empty());
776 }
777
778 #[test]
779 fn finds_a_nested_binary() {
780 let dir = std::env::temp_dir().join(format!("tak-find-{}", std::process::id()));
781 let nested = dir.join("tool-1.0").join("bin");
782 std::fs::create_dir_all(&nested).unwrap();
783 std::fs::write(nested.join("mycli"), b"#!/bin/sh\n").unwrap();
784
785 assert_eq!(find_binary(&dir, "mycli"), Some(nested.join("mycli")));
786 assert_eq!(find_binary(&dir, "absent"), None);
787
788 std::fs::remove_dir_all(&dir).ok();
789 }
790}