Skip to main content

tak_cli/
backfill.rs

1//! Backfill history from published release binaries.
2//!
3//! A new adopter's first chart is empty, and an empty chart persuades nobody.
4//! Rather than rebuilding a project at a hundred historical commits — hours of
5//! compute, and impossible for anyone without a reproducible build — this
6//! downloads the binaries the project already published and measures those.
7//! Minutes, not hours, and it works for projects whose old commits no longer
8//! build at all.
9//!
10//! Network and archive handling shell out to `curl` and `tar`/`unzip` for the
11//! same reason [`crate::notes`] shells out to `git`: proxies, CA bundles and
12//! credential helpers are already solved there, and the dependency tree stays
13//! small.
14
15use 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    /// Drafts are visible to authenticated requests and are not published
37    /// artefacts, so benchmarking one would record a version nobody can install.
38    #[serde(default)]
39    pub draft: bool,
40    #[serde(default)]
41    pub assets: Vec<Asset>,
42}
43
44/// GitHub's maximum, and the fewest round trips per page of history.
45const PER_PAGE: usize = 100;
46/// Bound the walk so a `--limit` larger than a project's history cannot spin.
47const 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
56/// Run curl, keeping the bearer token off the command line.
57///
58/// The split matters. A token passed as `-H` lands in argv, where any local
59/// process reading `/proc` recovers it, so it goes over stdin via `--config -`.
60/// The URL and output path are *not* secret and go as ordinary arguments —
61/// which also sidesteps curl's config quoting, where a value containing `"` or
62/// a newline would otherwise close the quoted field and inject further
63/// directives. A crafted `browser_download_url` could then add its own `url =`
64/// line and receive the Authorization header meant for GitHub.
65fn curl(url: &str, output: Option<&Path>, auth: bool) -> Result<Vec<u8>> {
66    // `proto`/`proto-redir` are the load-bearing part: `location` follows
67    // redirects, and without pinning the redirect protocol a downgrade to
68    // http:// on the same host would carry the Authorization header in clear.
69    // Checking only the initial URL is not enough.
70    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        // curl's config format has no escape for `"` inside a quoted value, and
76        // GitHub tokens are ASCII-alphanumeric with `_`, so a token containing
77        // a quote is malformed rather than something to escape.
78        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    // Only ever talk to https endpoints: a `http://` or `file://` redirect
85    // target would send the header in clear or read local files.
86    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
119/// Releases for `repo` ("owner/name"), newest first, pre-releases excluded.
120///
121/// Pages until `limit` qualifying releases are found or the history runs out —
122/// a first page consisting mostly of pre-releases would otherwise silently
123/// return far fewer than asked for.
124///
125/// Uses the GitHub API directly rather than mise-versions: that index only
126/// covers mise's curated registry, and backfill has to work for any project.
127pub 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
167/// Whether the picker's tables actually cover this host.
168///
169/// Kept in step with `asset_picker`'s own OS and arch tables; anything outside
170/// them leaves its token list empty, which disables the corresponding filter
171/// rather than rejecting.
172fn host_is_recognised() -> bool {
173    matches!(std::env::consts::OS, "linux" | "macos" | "windows")
174        && matches!(std::env::consts::ARCH, "x86_64" | "aarch64")
175}
176
177/// The libc to select assets for.
178///
179/// Passing `None` makes the picker assume gnu on Linux, which is right for the
180/// common case and wrong on Alpine, where a gnu binary simply will not start.
181/// A musl-built `tak` is a good proxy for a musl host, and it is the only
182/// signal available without shelling out.
183///
184/// This matters beyond whether the binary runs: musl and glibc builds have
185/// different allocators and different startup costs, so picking the wrong one
186/// measures something the project's users never execute.
187fn host_libc() -> Option<String> {
188    cfg!(target_env = "musl").then(|| "musl".to_string())
189}
190
191/// Can this asset actually serve as a benchmark subject?
192///
193/// The picker answers "which asset fits this platform", which is not the same
194/// question. It will happily choose a source tarball when that is the only
195/// candidate, or a `.7z` this code cannot unpack — and an unusable pick means a
196/// skipped release rather than a fallback to the next-best asset. Filtering
197/// first lets the picker choose the best of what remains.
198fn is_usable_subject(name: &str) -> bool {
199    let n = name.to_lowercase();
200
201    // A .vsix is a zip, so `Format` classifies it as openable, but it is a VS
202    // Code extension package — never a CLI binary. Admitting it wastes a
203    // download and then skips the release with a confusing "no `tool` inside".
204    if n.ends_with(".vsix") {
205        return false;
206    }
207
208    // Checksums and signatures describe a release rather than being one. The
209    // picker scores them positively — verified: given only
210    // `tool-x86_64-unknown-linux-gnu.tar.gz.sha256` it returns exactly that —
211    // because mise consumes sidecars deliberately elsewhere. Here they are
212    // never a subject.
213    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    // Source drops contain no executable. Whole-word so `resource-cli` survives.
229    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    // Only formats `fetch_binary` can actually open. Both sides ask `Format`
238    // rather than matching suffixes independently, so a name like `.vsix` —
239    // which is a zip — cannot pass the filter and then be rejected at unpack.
240    match Format::from_file_name(&n) {
241        // `Format::Raw` is a catch-all for "no recognised archive suffix",
242        // which also covers `tool-linux-x86_64.json`, `.yaml`, `.run` and
243        // anything else a project ships alongside its binaries. Those score on
244        // their platform tokens, and marking one executable and running it
245        // benchmarks the wrong program. A real bare binary has no extension at
246        // all, or `.exe`.
247        Format::Raw => {
248            let base = n.rsplit('/').next().unwrap_or(&n);
249            base.ends_with(".exe") || !has_file_extension(base)
250        }
251        // `tar` handles gz/xz/bz2/zst everywhere, but brotli and lz4 depend on
252        // the host build. Admitting one only to fail at extraction turns a
253        // usable release into a skipped one; excluding them lets the picker
254        // choose a different asset instead.
255        Format::Tar => !TAR_NEEDS_RARE_CODEC.iter().any(|e| n.ends_with(e)),
256        Format::Zip => true,
257        _ => false,
258    }
259}
260
261/// Does this filename end in something that looks like a file extension?
262///
263/// "Contains a dot" is not the same question. A raw unix binary is routinely
264/// published as `tool-v1.2.3-linux-x86_64`, and treating the version's dots as
265/// an extension rejects a perfectly good subject. An extension is short, purely
266/// alphanumeric, and not just digits — `json` and `yaml` qualify, the `3` at
267/// the end of a version does not.
268fn 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
280/// Tar compressions that `tar` frequently cannot decompress unaided.
281const TAR_NEEDS_RARE_CODEC: [&str; 4] = [".tar.br", ".tbr", ".tar.lz4", ".tlz4"];
282
283pub fn pick_asset(assets: &[Asset]) -> Option<&Asset> {
284    // The picker's platform tables cover the mainstream targets and skip the
285    // OS or arch filter entirely when it does not recognise one — which on
286    // FreeBSD or riscv64 would let a linux-x86_64 tarball match a machine that
287    // cannot run it. Refusing to guess is the right answer for a tool whose
288    // whole purpose is trustworthy numbers.
289    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    // If any candidate states a platform, the ones that do not are not
300    // trustworthy. Verified against the picker: a release shipping
301    // `tool-x86_64-unknown-linux-gnu.tar.gz` alongside `tool.tar.gz` hands the
302    // latter to a macOS host, because the linux asset is filtered out and the
303    // unlabelled one has nothing to filter on. In a release that publishes
304    // per-platform builds, an unlabelled archive is some other artefact — not a
305    // universal binary — so measuring it is measuring the wrong thing.
306    //
307    // A genuinely universal release, where nothing declares a platform, is
308    // untouched by this.
309    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    // A macOS `.app` bundle is not something we can invoke as a subject.
324    .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
343/// Download `asset` into `dir`, unpack it, and return the path to `bin_name`.
344///
345/// Handles tarballs, zips and bare executables — the three shapes essentially
346/// every CLI ships. The download is authenticated for the same reason the
347/// listing is: a private repository's assets are not publicly readable, and
348/// failing here after listing succeeded would skip every release.
349pub fn fetch_binary(asset: &Asset, bin_name: &str, dir: &Path) -> Result<PathBuf> {
350    // Start clean. Reusing a populated directory risks `find_binary` picking up
351    // an executable left by an earlier release and attributing its measurement
352    // to the wrong tag.
353    std::fs::remove_dir_all(dir).ok();
354    std::fs::create_dir_all(dir)?;
355    // Asset names come from the API and are not trusted: `../../x` would escape
356    // the per-release directory and write wherever it liked.
357    let archive = dir.join(safe_component(&asset.name)?);
358
359    curl(&asset.url, Some(&archive), true)?;
360
361    // Same classifier the candidate filter used, so the two can never disagree.
362    // Matching suffixes independently in each place is what let `.vsix` pass the
363    // filter as a zip and then be rejected here as unknown.
364    match Format::from_file_name(&asset.name) {
365        // `tar -xf` sniffs the compression, so gz/xz/bz2/zst all work here.
366        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        // The download is the artefact.
385        Format::Raw => {
386            make_executable(&archive)?;
387            return Ok(archive);
388        }
389        // Unreachable via `pick_asset`, which filters these out, but a caller
390        // could hand one over directly. Skipping loudly beats executing a
391        // tarball as if it were a program, which is what the old suffix chain
392        // did for anything it did not recognise.
393        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
402/// Reduce an untrusted name to a single safe path component.
403///
404/// `Path::join` with a value containing `..` or separators escapes the intended
405/// directory, and both asset names and tags come from the GitHub API.
406fn 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
415/// A filesystem-safe, collision-free directory name for a release.
416///
417/// Sanitising alone is not enough: tags may contain `/` (`release/1.0`), and
418/// mapping both `v1/0` and `v1_0` to `v1_0` would let two releases share a
419/// directory. The index keeps them distinct.
420pub fn release_dir_name(index: usize, tag: &str) -> String {
421    format!("{index:04}-{}", safe_dir_name(tag))
422}
423
424/// A filesystem-safe directory name for a release tag.
425///
426/// Tags may contain `/` (`release/1.0`) and are otherwise arbitrary, so anything
427/// outside a conservative set becomes `_`.
428fn 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
460/// Names to accept for the executable.
461///
462/// Windows archives ship `tool.exe`, while `--bin` defaults to the repository
463/// name without a suffix, so an exact-match-only search skips every Windows
464/// release.
465fn 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
473/// Depth-limited search for the executable. Release archives nest one or two
474/// levels at most; a full walk risks wandering into a vendored tree.
475fn 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
499/// Whether the current directory is inside a git work tree.
500///
501/// Distinguishes "not in a repository" from "tag not fetched", which otherwise
502/// both surface as a missing tag and send people looking in the wrong place.
503pub 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
513/// Resolve a release tag to the commit it points at, if the tag is available
514/// locally. Returns `None` rather than failing — a shallow clone legitimately
515/// has no tags, and the caller can still report that precisely.
516pub 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
526/// Strip a leading `v` for display. Deliberately does no other normalisation —
527/// tool version strings are frequently not semver and must stay opaque.
528pub 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        // Only meaningful on the platform this asserts against.
552        if std::env::consts::OS == "linux" && std::env::consts::ARCH == "x86_64" {
553            let got = pick_asset(&assets).expect("something should match");
554            // gnu, not musl, on a glibc host. The two builds differ in
555            // allocator and startup cost, so the one users actually run is the
556            // one worth measuring — and it is never the darwin asset or the
557            // checksum file.
558            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        // Non-semver tags must survive untouched.
572        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        // An explicit .exe must not become mycli.exe.exe.
584        assert_eq!(binary_candidates("mycli.exe").len(), 1);
585    }
586
587    #[test]
588    fn source_archives_are_not_benchmark_subjects() {
589        // The picker selects these when they are the only candidate; they
590        // contain no executable.
591        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        // Whole-word, so a tool whose name merely contains "src" survives.
595        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        // Verified against the real picker: given only the .sha256 it returns
602        // exactly that, so the filter has to catch them.
603        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    /// A .vsix unpacks fine — it is a zip — but never contains a CLI binary,
614    /// so admitting it costs a download and a confusing skip.
615    #[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    /// `Format::Raw` means "no recognised archive suffix", which is not the
622    /// same as "an executable".
623    #[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        // A genuine bare binary still qualifies.
634        assert!(is_usable_subject("tool-linux-x86_64"));
635        assert!(is_usable_subject("tool.exe"));
636    }
637
638    /// A version's dots are not a file extension, and rejecting on "contains a
639    /// dot" threw away raw binaries that projects really do publish.
640    #[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        // Trailing version components, not extensions.
653        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        // Better to let the picker choose another asset than to fail at
663        // extraction and skip the release entirely.
664        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        // fetch_binary cannot open these, and picking one skips the release.
675        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    /// The picker disables a filter it cannot populate, so an unrecognised host
701    /// must be refused before it ever gets there.
702    #[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    /// The case that motivated the platform-evidence rule: a release with
718    /// per-platform builds, none of them ours, plus an unlabelled archive.
719    #[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        // On linux/x86_64 the labelled asset is the right answer.
730        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        // On macOS the linux asset does not apply, and falling back to
737        // `tool.tar.gz` would benchmark whatever that happens to be.
738        if std::env::consts::OS == "macos" {
739            assert!(pick_asset(&assets).is_none());
740        }
741    }
742
743    /// A release where nothing declares a platform is still usable — that is a
744    /// real and common shape, and rejecting it would exclude those projects.
745    #[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        // Sanitising alone maps both of these to `v1_0`.
766        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        // Path separators must not survive into the name.
773        assert!(!release_dir_name(0, "release/1.0").contains('/'));
774        // A tag of only punctuation still yields something usable.
775        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}