diff --git a/internal/buildmeta/buildmeta.go b/internal/buildmeta/buildmeta.go index 77b92ef..28f7821 100644 --- a/internal/buildmeta/buildmeta.go +++ b/internal/buildmeta/buildmeta.go @@ -30,7 +30,10 @@ type Arch string const ( ArchAny Arch = "ANYARCH" - ArchAMD64 Arch = "x86_64" + ArchAMD64 Arch = "x86_64" // baseline (v1) + ArchAMD64v2 Arch = "x86_64_v2" // +SSE4, +POPCNT, etc. + ArchAMD64v3 Arch = "x86_64_v3" // +AVX2, +BMI, etc. + ArchAMD64v4 Arch = "x86_64_v4" // +AVX-512 ArchARM64 Arch = "aarch64" ArchARMv7 Arch = "armv7" ArchARMv6 Arch = "armv6" @@ -98,3 +101,41 @@ type Target struct { func (t Target) Triplet() string { return string(t.OS) + "-" + string(t.Arch) + "-" + string(t.Libc) } + +// ArchFallbacks returns the architectures that a machine with the given +// arch can run, ordered from most specific to least. The input arch is +// always first. Returns nil for unknown architectures. +// +// For example, an amd64v4 machine can run v4, v3, v2, and baseline (v1) +// binaries. An armv7 machine can run armv7 and armv6 binaries. +func ArchFallbacks(arch Arch) []Arch { + switch arch { + case ArchAMD64v4: + return []Arch{ArchAMD64v4, ArchAMD64v3, ArchAMD64v2, ArchAMD64} + case ArchAMD64v3: + return []Arch{ArchAMD64v3, ArchAMD64v2, ArchAMD64} + case ArchAMD64v2: + return []Arch{ArchAMD64v2, ArchAMD64} + case ArchARMv7: + return []Arch{ArchARMv7, ArchARMv6} + default: + // No fallback chain — exact match only. + return []Arch{arch} + } +} + +// LibcFallbacks returns the libc variants a machine can use, ordered +// by preference. A musl system can only run musl or static binaries. +// A glibc system can only run glibc or static binaries. +func LibcFallbacks(libc Libc) []Libc { + switch libc { + case LibcGNU: + return []Libc{LibcGNU, LibcNone} + case LibcMusl: + return []Libc{LibcMusl, LibcNone} + case LibcMSVC: + return []Libc{LibcMSVC, LibcNone} + default: + return []Libc{libc} + } +} diff --git a/internal/classify/classify.go b/internal/classify/classify.go index 310cac9..c990026 100644 --- a/internal/classify/classify.go +++ b/internal/classify/classify.go @@ -32,11 +32,25 @@ func (r Result) Target() buildmeta.Target { // Filename classifies a release asset filename, returning the detected // OS, architecture, libc, and archive format. Undetected fields are empty. +// +// OS is detected first because it can influence arch interpretation. +// For example, "windows-arm" in modern releases means ARM64, while +// bare "arm" on Linux historically means ARMv6. func Filename(name string) Result { lower := strings.ToLower(name) + os := detectOS(lower) + arch := detectArch(lower) + + // On Windows, bare "arm" (detected as ARMv6) almost certainly means + // ARM64. Windows never shipped ARMv6 binaries — "ARM" became the + // marketing label for ARM64 (Windows on ARM). + if os == buildmeta.OSWindows && arch == buildmeta.ArchARMv6 { + arch = buildmeta.ArchARM64 + } + return Result{ - OS: detectOS(lower), - Arch: detectArch(lower), + OS: os, + Arch: arch, Libc: detectLibc(lower), Format: detectFormat(lower), } @@ -78,12 +92,16 @@ var archPatterns = []struct { arch buildmeta.Arch pattern *regexp.Regexp }{ - // amd64 before x86 — "x86_64" must not match as x86. + // amd64 micro-levels before baseline — "amd64v3" must not fall through to amd64. + {buildmeta.ArchAMD64v4, regexp.MustCompile(`(?i)(?:x86[_-]64[_-]v4|amd64v4|v4-amd64)`)}, + {buildmeta.ArchAMD64v3, regexp.MustCompile(`(?i)(?:x86[_-]64[_-]v3|amd64v3|v3-amd64)`)}, + {buildmeta.ArchAMD64v2, regexp.MustCompile(`(?i)(?:x86[_-]64[_-]v2|amd64v2|v2-amd64)`)}, + // amd64 baseline before x86 — "x86_64" must not match as x86. {buildmeta.ArchAMD64, regexp.MustCompile(`(?i)(?:x86[_-]64|amd64|x64|64-bit)`)}, // arm64 before armv7/armv6 — "aarch64" must not match as arm. {buildmeta.ArchARM64, regexp.MustCompile(`(?i)(?:aarch64|arm64|armv8)`)}, {buildmeta.ArchARMv7, regexp.MustCompile(`(?i)(?:armv7l?|arm-?v7|arm32)`)}, - {buildmeta.ArchARMv6, regexp.MustCompile(`(?i)(?:armv6l?|arm-?v6|aarch32)`)}, + {buildmeta.ArchARMv6, regexp.MustCompile(`(?i)(?:armv6l?|arm-?v6|aarch32|` + b + `arm` + bEnd + `)`)}, // ppc64le before ppc64. {buildmeta.ArchPPC64LE, regexp.MustCompile(`(?i)ppc64le`)}, {buildmeta.ArchPPC64, regexp.MustCompile(`(?i)ppc64`)}, diff --git a/internal/classify/classify_test.go b/internal/classify/classify_test.go index 420184e..e48acd0 100644 --- a/internal/classify/classify_test.go +++ b/internal/classify/classify_test.go @@ -151,6 +151,46 @@ func TestFilename(t *testing.T) { format: buildmeta.FormatTarGz, }, + // Windows ARM: bare "arm" means ARM64, not ARMv6/v7 + { + name: "windows arm means arm64", + input: "tool-1.0.0-windows-arm.zip", + wantOS: buildmeta.OSWindows, + arch: buildmeta.ArchARM64, + format: buildmeta.FormatZip, + }, + { + name: "windows arm64 stays arm64", + input: "tool-1.0.0-windows-arm64.zip", + wantOS: buildmeta.OSWindows, + arch: buildmeta.ArchARM64, + format: buildmeta.FormatZip, + }, + + // amd64 micro-architecture levels + { + name: "amd64v2", + input: "tool-linux-amd64v2.tar.gz", + arch: buildmeta.ArchAMD64v2, + }, + { + name: "amd64v3", + input: "tool-linux-x86_64_v3.tar.gz", + arch: buildmeta.ArchAMD64v3, + }, + { + name: "amd64v4", + input: "tool-linux-amd64v4.tar.gz", + arch: buildmeta.ArchAMD64v4, + }, + { + name: "amd64v3 not baseline", + input: "tool-1.0.0-linux-amd64v3.tar.gz", + wantOS: buildmeta.OSLinux, + arch: buildmeta.ArchAMD64v3, + format: buildmeta.FormatTarGz, + }, + // ARM variants: arm64 must not match armv7/armv6 { name: "aarch64 not armv7", diff --git a/internal/platlatest/platlatest.go b/internal/platlatest/platlatest.go index 7e28d90..d72c0e1 100644 --- a/internal/platlatest/platlatest.go +++ b/internal/platlatest/platlatest.go @@ -22,6 +22,7 @@ import ( "sync" "github.com/webinstall/webi-installers/internal/buildmeta" + "github.com/webinstall/webi-installers/internal/lexver" ) // Index tracks the latest version for each build target of a package. @@ -78,6 +79,41 @@ func (idx *Index) All() map[string]string { return out } +// Resolve finds the latest compatible version for a target by walking +// the arch and libc fallback chains. It prefers the newest version over +// the most specific arch match. +// +// For example, on an amd64v4 machine with glibc: +// - v2.0.0 has amd64 (baseline) → candidate "v2.0.0" +// - v1.0.0 has amd64v4 → candidate "v1.0.0" +// - Returns "v2.0.0" because it's newer, even though v1.0.0 +// is a more specific arch match. +// +// Returns the best version and the exact target it matched, or "" if +// no compatible version exists. +func (idx *Index) Resolve(t buildmeta.Target) (version string, matched buildmeta.Target) { + idx.mu.RLock() + defer idx.mu.RUnlock() + + arches := buildmeta.ArchFallbacks(t.Arch) + libcs := buildmeta.LibcFallbacks(t.Libc) + + for _, arch := range arches { + for _, libc := range libcs { + candidate := buildmeta.Target{OS: t.OS, Arch: arch, Libc: libc} + v := idx.m[candidate.Triplet()] + if v == "" { + continue + } + if version == "" || lexver.Compare(lexver.Parse(v), lexver.Parse(version)) > 0 { + version = v + matched = candidate + } + } + } + return version, matched +} + // Save persists the index to disk (atomic write). func (idx *Index) Save() error { idx.mu.RLock() diff --git a/internal/platlatest/platlatest_test.go b/internal/platlatest/platlatest_test.go index 82f2539..92f8d49 100644 --- a/internal/platlatest/platlatest_test.go +++ b/internal/platlatest/platlatest_test.go @@ -91,6 +91,113 @@ func TestAll(t *testing.T) { } } +func TestResolveArchFallback(t *testing.T) { + p := filepath.Join(t.TempDir(), "latest.json") + idx, err := platlatest.Open(p) + if err != nil { + t.Fatal(err) + } + + // v1.0.0 had per-microarch builds. + idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64v4, Libc: buildmeta.LibcGNU}, "v1.0.0") + idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64v3, Libc: buildmeta.LibcGNU}, "v1.0.0") + idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64v2, Libc: buildmeta.LibcGNU}, "v1.0.0") + idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU}, "v1.0.0") + + // v2.0.0 dropped microarch, ships only baseline amd64. + idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU}, "v2.0.0") + + // An amd64v4 machine should get v2.0.0 (latest) via baseline fallback, + // not v1.0.0 (which had a specific v4 build). + want := buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64v4, Libc: buildmeta.LibcGNU} + ver, matched := idx.Resolve(want) + if ver != "v2.0.0" { + t.Errorf("Resolve(amd64v4) version = %q, want v2.0.0", ver) + } + if matched.Arch != buildmeta.ArchAMD64 { + t.Errorf("Resolve(amd64v4) matched arch = %q, want %q", matched.Arch, buildmeta.ArchAMD64) + } +} + +func TestResolveExactMatchPreferred(t *testing.T) { + p := filepath.Join(t.TempDir(), "latest.json") + idx, err := platlatest.Open(p) + if err != nil { + t.Fatal(err) + } + + // Both amd64v3 and baseline have the same latest version. + idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64v3, Libc: buildmeta.LibcGNU}, "v2.0.0") + idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU}, "v2.0.0") + + // When versions are equal, the more specific arch should win + // (it appears first in the fallback chain). + want := buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64v3, Libc: buildmeta.LibcGNU} + ver, matched := idx.Resolve(want) + if ver != "v2.0.0" { + t.Errorf("version = %q, want v2.0.0", ver) + } + if matched.Arch != buildmeta.ArchAMD64v3 { + t.Errorf("matched arch = %q, want %q (more specific)", matched.Arch, buildmeta.ArchAMD64v3) + } +} + +func TestResolveLibcFallback(t *testing.T) { + p := filepath.Join(t.TempDir(), "latest.json") + idx, err := platlatest.Open(p) + if err != nil { + t.Fatal(err) + } + + // Only a static (LibcNone) build exists. + idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcNone}, "v1.0.0") + + // A glibc machine should find it via libc fallback. + want := buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU} + ver, matched := idx.Resolve(want) + if ver != "v1.0.0" { + t.Errorf("version = %q, want v1.0.0", ver) + } + if matched.Libc != buildmeta.LibcNone { + t.Errorf("matched libc = %q, want %q", matched.Libc, buildmeta.LibcNone) + } +} + +func TestResolveNoMatch(t *testing.T) { + p := filepath.Join(t.TempDir(), "latest.json") + idx, err := platlatest.Open(p) + if err != nil { + t.Fatal(err) + } + + idx.Set(linuxAMD64, "v1.0.0") + + // Darwin target should not match a Linux entry. + ver, _ := idx.Resolve(darwinARM64) + if ver != "" { + t.Errorf("Resolve(darwin) = %q, want empty (no match)", ver) + } +} + +func TestResolveBaselineOnly(t *testing.T) { + p := filepath.Join(t.TempDir(), "latest.json") + idx, err := platlatest.Open(p) + if err != nil { + t.Fatal(err) + } + + // amd64v1 machine can't run v2+ binaries. + idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64v2, Libc: buildmeta.LibcGNU}, "v2.0.0") + idx.Set(buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU}, "v1.0.0") + + // Baseline machine gets v1.0.0 — it can't run v2's amd64v2 binary. + want := buildmeta.Target{OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU} + ver, _ := idx.Resolve(want) + if ver != "v1.0.0" { + t.Errorf("Resolve(amd64 baseline) = %q, want v1.0.0", ver) + } +} + func TestOpenNonexistent(t *testing.T) { p := filepath.Join(t.TempDir(), "does-not-exist.json") idx, err := platlatest.Open(p)