package main import ( "fmt" "os" "os/exec" "regexp" "sort" "strings" "sync" ) // Aliases commonly used in release asset names, including Rust target triples. func osAlias(o string) []string { switch o { case "darwin": return []string{"darwin", "macos", "osx", "mac", "apple"} case "windows": return []string{"windows", "win"} } return []string{o} } func archAlias(a string) []string { switch a { case "amd64": return []string{"amd64", "x86_64", "x64", "64bit"} case "arm64": return []string{"arm64", "aarch64"} case "386": return []string{"386", "i386", "i686", "x86", "32bit"} case "arm": return []string{"arm", "armv7", "armv6", "armhf"} case "riscv64": return []string{"riscv64", "riscv64gc"} } return []string{a} } var ( // Checksums, signatures and OS packages are not plain binaries. notBinaryRe = regexp.MustCompile(`\.(sha\d*|sha256sum|md5|asc|sig|pem|sbom|json|txt|deb|rpm|apk|dmg|pkg|msi|snap|flatpak|appimage)$`) // Cross-compile targets whose triples contain a host OS token: // aarch64-linux-android is Android, not Linux. foreignRe = regexp.MustCompile(`(^|[^a-z0-9])(android\w*|ios|wasi|wasm\w*|emscripten)([^a-z0-9]|$)`) archiveRe = regexp.MustCompile(`\.(tar\.gz|tgz|zip|tar\.xz|tar\.bz2|tar\.zst|tzst)$`) noiseRe = regexp.MustCompile(`debug|symbols|static-pie|profile`) tarballRe = regexp.MustCompile(`\.(tar\.gz|tgz|tar\.bz2|tbz|tar\.xz|txz|tar\.zst|tzst|tar)$`) singleRe = regexp.MustCompile(`\.(gz|bz2|xz|zst|lz4|lzma)$`) refuseRe = regexp.MustCompile(`\.(rar|dmg|pkg|msi|deb|rpm)$`) widerArm = regexp.MustCompile(`arm64|aarch64`) wider386 = regexp.MustCompile(`x86[_-]?64|amd64`) win64Re = regexp.MustCompile(`(^|[^a-z0-9])win64($|[^a-z0-9])`) win32Re = regexp.MustCompile(`(^|[^a-z0-9])win32($|[^a-z0-9])`) ) // Compound tokens that name OS and architecture in one word. Rewritten before // matching so the normal token rules apply. "win32" is Node-speak for Windows // in general (pnpm-win32-x64.zip), so it contributes no architecture. func normalizeTokens(n string) string { n = win64Re.ReplaceAllString(n, "${1}windows-amd64${2}") n = win32Re.ReplaceAllString(n, "${1}windows${2}") return n } // tokenRe matches any of the words as a token, i.e. not glued to letters or // digits. RE2 has no look-around, so the boundaries are ordinary characters. func tokenRe(words []string) *regexp.Regexp { quoted := make([]string, len(words)) for i, w := range words { quoted[i] = regexp.QuoteMeta(w) } return regexp.MustCompile(`(^|[^a-z0-9])(` + strings.Join(quoted, "|") + `)([^a-z0-9]|$)`) } // Can this machine turn the asset into a binary? tar, zip, gzip and bzip2 are // handled in-process; the rest needs a helper on $PATH. func unpackable(n string) bool { n = strings.ToLower(n) switch { case regexp.MustCompile(`\.(tar\.gz|tgz|tar\.bz2|tbz|tar|zip)$`).MatchString(n): return true case regexp.MustCompile(`\.(tar\.xz|txz)$`).MatchString(n): return decompressor("xz") != nil case regexp.MustCompile(`\.(tar\.zst|tzst)$`).MatchString(n): return decompressor("zst") != nil case strings.HasSuffix(n, ".7z"): return sevenZip() != "" case singleRe.MatchString(n): return decompressor(singleRe.FindStringSubmatch(n)[1]) != nil case refuseRe.MatchString(n): return false } return true // bare binary or an unversioned name } // Glibc vs musl builds: pick what this system actually runs. var muslOnce = sync.OnceValue(func() bool { if _, err := os.Stat("/etc/alpine-release"); err == nil { return true } ldd, err := exec.LookPath("ldd") if err != nil { return false } out, _ := exec.Command(ldd, "--version").CombinedOutput() return strings.Contains(strings.ToLower(string(out)), "musl") }) type candidate struct { score int pref int length int asset *Asset } // pickAsset mirrors the Perl scoring one to one - the corpus test in // asset_test.go checks that the two keep agreeing. func pickAsset(assets []Asset, bin, wantOS, wantArch string, spec *Spec) (*Asset, error) { if spec.Asset != "" { for i := range assets { if assets[i].Name == spec.Asset { return &assets[i], nil } } return nil, fmt.Errorf("asset %q is not part of the release", spec.Asset) } if spec.Pattern != "" { re, err := regexp.Compile(spec.Pattern) if err != nil { return nil, fmt.Errorf("invalid --pattern: %w", err) } for i := range assets { if re.MatchString(assets[i].Name) { return &assets[i], nil } } return nil, nil } arches := archAlias(wantArch) // Universal macOS builds serve both architectures. if wantOS == "darwin" { arches = append(arches, "universal", "universal2") } osRe, archRe := tokenRe(osAlias(wantOS)), tokenRe(arches) var cands []candidate var rejected []string for i := range assets { as := &assets[i] n := strings.ToLower(as.Name) m := normalizeTokens(n) switch { case notBinaryRe.MatchString(n): rejected = append(rejected, n+" (not a binary)") continue case foreignRe.MatchString(m): rejected = append(rejected, n+" (foreign target)") continue case !osRe.MatchString(m): rejected = append(rejected, n+" (os)") continue case !archRe.MatchString(m): rejected = append(rejected, n+" (arch)") continue // Keep 32-bit "arm" off "arm64" and "386" off "x86_64". case wantArch == "arm" && widerArm.MatchString(m), wantArch == "386" && wider386.MatchString(m), wantArch == "amd64" && widerArm.MatchString(m): rejected = append(rejected, n+" (wider arch)") continue } score := 0 if bin != "" && strings.HasPrefix(n, strings.ToLower(bin)) { score += 10 // named after the binary } if archiveRe.MatchString(n) || !strings.Contains(n, ".") { score += 3 } if noiseRe.MatchString(n) { score -= 5 } // Only avoid formats this machine has no tool for - restic, for one, // ships nothing but .bz2, and that is handled in-process. if !unpackable(n) { score -= 8 } // Toolchain preference is a tie-break, not a penalty: a project that // ships musl only (or mingw only) must not be downranked for it. pref := 0 switch { case wantOS == "linux" && (strings.Contains(n, "musl") || strings.Contains(n, "gnu")): libc := "gnu" if muslOnce() { libc = "musl" } if !strings.Contains(n, libc) { pref = 1 } case wantOS == "windows" && (strings.Contains(n, "msvc") || strings.Contains(n, "gnu")): if !strings.Contains(n, "msvc") { // msvc is the normal Windows build pref = 1 } } cands = append(cands, candidate{score: score, pref: pref, length: len(n), asset: as}) } // Best score, then preferred toolchain, then the shortest name (which // avoids special variants like -baseline or -static). sort.SliceStable(cands, func(i, j int) bool { a, b := cands[i], cands[j] if a.score != b.score { return a.score > b.score } if a.pref != b.pref { return a.pref < b.pref } return a.length < b.length }) if opt.verbose { for _, c := range cands { verbose("candidate score=%-3d pref=%d %s", c.score, c.pref, c.asset.Name) } for _, r := range rejected { verbose("rejected: %s", r) } } if len(cands) == 0 { return nil, nil } return cands[0].asset, nil }