239 lines
7.0 KiB
Go
239 lines
7.0 KiB
Go
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
|
|
}
|