From 1e26a3e5ec22c185dbd0c6b279fabd3d6f85b0aa Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 9 Mar 2026 21:33:59 -0600 Subject: [PATCH] feat: add classify and platlatest packages classify extracts OS, arch, libc, and format from release asset filenames using regex pattern matching with priority ordering (x86_64 before x86, arm64 before armv7, etc.). platlatest tracks the newest release version per build target (OS+arch+libc triplet) to handle the common case where Windows or macOS releases lag behind Linux by several versions. --- internal/classify/classify.go | 162 ++++++++++++++++ internal/classify/classify_test.go | 257 +++++++++++++++++++++++++ internal/platlatest/platlatest.go | 104 ++++++++++ internal/platlatest/platlatest_test.go | 104 ++++++++++ 4 files changed, 627 insertions(+) create mode 100644 internal/classify/classify.go create mode 100644 internal/classify/classify_test.go create mode 100644 internal/platlatest/platlatest.go create mode 100644 internal/platlatest/platlatest_test.go diff --git a/internal/classify/classify.go b/internal/classify/classify.go new file mode 100644 index 0000000..310cac9 --- /dev/null +++ b/internal/classify/classify.go @@ -0,0 +1,162 @@ +// Package classify extracts build targets from release asset filenames. +// +// Standard toolchains (goreleaser, cargo-dist, zig build) produce predictable +// filenames like "tool_0.1.0_linux_amd64.tar.gz" or +// "tool-0.1.0-x86_64-unknown-linux-musl.tar.gz". This package matches those +// patterns directly using regex, avoiding heuristic guessing. +// +// Detection order matters: architectures are checked longest-first to prevent +// "x86" from matching inside "x86_64", and OS checks use word boundaries. +package classify + +import ( + "path" + "regexp" + "strings" + + "github.com/webinstall/webi-installers/internal/buildmeta" +) + +// Result holds the classification of an asset filename. +type Result struct { + OS buildmeta.OS + Arch buildmeta.Arch + Libc buildmeta.Libc + Format buildmeta.Format +} + +// Target returns the build target (OS + Arch + Libc). +func (r Result) Target() buildmeta.Target { + return buildmeta.Target{OS: r.OS, Arch: r.Arch, Libc: r.Libc} +} + +// Filename classifies a release asset filename, returning the detected +// OS, architecture, libc, and archive format. Undetected fields are empty. +func Filename(name string) Result { + lower := strings.ToLower(name) + return Result{ + OS: detectOS(lower), + Arch: detectArch(lower), + Libc: detectLibc(lower), + Format: detectFormat(lower), + } +} + +// b is a boundary: start/end of string or a non-alphanumeric separator. +// Go's RE2 doesn't support \b, so we use this instead. +const b = `(?:^|[^a-zA-Z0-9])` +const bEnd = `(?:[^a-zA-Z0-9]|$)` + +// --- OS detection --- + +var osPatterns = []struct { + os buildmeta.OS + pattern *regexp.Regexp +}{ + {buildmeta.OSDarwin, regexp.MustCompile(`(?i)` + b + `(?:darwin|macos|osx|os-x|apple)` + bEnd)}, + {buildmeta.OSLinux, regexp.MustCompile(`(?i)` + b + `linux` + bEnd)}, + {buildmeta.OSWindows, regexp.MustCompile(`(?i)` + b + `(?:windows|win(?:32|64|dows)?)` + bEnd + `|\.exe(?:\.xz)?$|\.msi$`)}, + {buildmeta.OSFreeBSD, regexp.MustCompile(`(?i)` + b + `freebsd` + bEnd)}, + {buildmeta.OSSunOS, regexp.MustCompile(`(?i)` + b + `(?:sunos|solaris|illumos)` + bEnd)}, + {buildmeta.OSAIX, regexp.MustCompile(`(?i)` + b + `aix` + bEnd)}, + {buildmeta.OSAndroid, regexp.MustCompile(`(?i)` + b + `android` + bEnd)}, +} + +func detectOS(lower string) buildmeta.OS { + for _, p := range osPatterns { + if p.pattern.MatchString(lower) { + return p.os + } + } + return "" +} + +// --- Arch detection --- +// Order matters: check longer/more-specific patterns first. + +var archPatterns = []struct { + arch buildmeta.Arch + pattern *regexp.Regexp +}{ + // amd64 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)`)}, + // ppc64le before ppc64. + {buildmeta.ArchPPC64LE, regexp.MustCompile(`(?i)ppc64le`)}, + {buildmeta.ArchPPC64, regexp.MustCompile(`(?i)ppc64`)}, + {buildmeta.ArchS390X, regexp.MustCompile(`(?i)s390x`)}, + {buildmeta.ArchMIPS64, regexp.MustCompile(`(?i)mips64`)}, + {buildmeta.ArchMIPS, regexp.MustCompile(`(?i)` + b + `mips` + bEnd)}, + // x86 last — must not steal x86_64. + {buildmeta.ArchX86, regexp.MustCompile(`(?i)(?:` + b + `x86` + bEnd + `|i[3-6]86|32-bit)`)}, +} + +func detectArch(lower string) buildmeta.Arch { + for _, p := range archPatterns { + if p.pattern.MatchString(lower) { + return p.arch + } + } + return "" +} + +// --- Libc detection --- + +var ( + reMusl = regexp.MustCompile(`(?i)` + b + `musl` + bEnd) + reGNU = regexp.MustCompile(`(?i)` + b + `(?:gnu|glibc)` + bEnd) + reMSVC = regexp.MustCompile(`(?i)` + b + `msvc` + bEnd) + reStatic = regexp.MustCompile(`(?i)` + b + `static` + bEnd) +) + +func detectLibc(lower string) buildmeta.Libc { + switch { + case reMusl.MatchString(lower): + return buildmeta.LibcMusl + case reGNU.MatchString(lower): + return buildmeta.LibcGNU + case reMSVC.MatchString(lower): + return buildmeta.LibcMSVC + case reStatic.MatchString(lower): + return buildmeta.LibcNone + } + return "" +} + +// --- Format detection --- + +// formatSuffixes maps file extensions to formats, longest first. +var formatSuffixes = []struct { + suffix string + format buildmeta.Format +}{ + {".tar.gz", buildmeta.FormatTarGz}, + {".tar.xz", buildmeta.FormatTarXz}, + {".tar.zst", buildmeta.FormatTarZst}, + {".exe.xz", buildmeta.FormatExeXz}, + {".app.zip", buildmeta.FormatAppZip}, + {".tgz", buildmeta.FormatTarGz}, + {".zip", buildmeta.FormatZip}, + {".gz", buildmeta.FormatGz}, + {".xz", buildmeta.FormatXz}, + {".zst", buildmeta.FormatZst}, + {".7z", buildmeta.Format7z}, + {".exe", buildmeta.FormatExe}, + {".msi", buildmeta.FormatMSI}, + {".dmg", buildmeta.FormatDMG}, + {".pkg", buildmeta.FormatPkg}, +} + +func detectFormat(lower string) buildmeta.Format { + // Use the base name to avoid directory separators confusing suffix matching. + base := path.Base(lower) + for _, s := range formatSuffixes { + if strings.HasSuffix(base, s.suffix) { + return s.format + } + } + return "" +} diff --git a/internal/classify/classify_test.go b/internal/classify/classify_test.go new file mode 100644 index 0000000..420184e --- /dev/null +++ b/internal/classify/classify_test.go @@ -0,0 +1,257 @@ +package classify_test + +import ( + "testing" + + "github.com/webinstall/webi-installers/internal/buildmeta" + "github.com/webinstall/webi-installers/internal/classify" +) + +func TestFilename(t *testing.T) { + tests := []struct { + name string + input string + wantOS buildmeta.OS + arch buildmeta.Arch + libc buildmeta.Libc + format buildmeta.Format + }{ + // Goreleaser-style + { + name: "goreleaser linux amd64 tar.gz", + input: "hugo_0.145.0_linux-amd64.tar.gz", + wantOS: buildmeta.OSLinux, + arch: buildmeta.ArchAMD64, + format: buildmeta.FormatTarGz, + }, + { + name: "goreleaser darwin arm64 tar.gz", + input: "hugo_0.145.0_darwin-arm64.tar.gz", + wantOS: buildmeta.OSDarwin, + arch: buildmeta.ArchARM64, + format: buildmeta.FormatTarGz, + }, + { + name: "goreleaser windows amd64 zip", + input: "hugo_0.145.0_windows-amd64.zip", + wantOS: buildmeta.OSWindows, + arch: buildmeta.ArchAMD64, + format: buildmeta.FormatZip, + }, + { + name: "goreleaser freebsd", + input: "hugo_0.145.0_freebsd-amd64.tar.gz", + wantOS: buildmeta.OSFreeBSD, + arch: buildmeta.ArchAMD64, + format: buildmeta.FormatTarGz, + }, + + // Rust/cargo-dist style + { + name: "rust linux musl", + input: "ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz", + wantOS: buildmeta.OSLinux, + arch: buildmeta.ArchAMD64, + libc: buildmeta.LibcMusl, + format: buildmeta.FormatTarGz, + }, + { + name: "rust linux gnu", + input: "bat-v0.24.0-x86_64-unknown-linux-gnu.tar.gz", + wantOS: buildmeta.OSLinux, + arch: buildmeta.ArchAMD64, + libc: buildmeta.LibcGNU, + format: buildmeta.FormatTarGz, + }, + { + name: "rust apple darwin", + input: "ripgrep-14.1.1-x86_64-apple-darwin.tar.gz", + wantOS: buildmeta.OSDarwin, + arch: buildmeta.ArchAMD64, + format: buildmeta.FormatTarGz, + }, + { + name: "rust windows msvc", + input: "bat-v0.24.0-x86_64-pc-windows-msvc.zip", + wantOS: buildmeta.OSWindows, + arch: buildmeta.ArchAMD64, + libc: buildmeta.LibcMSVC, + format: buildmeta.FormatZip, + }, + { + name: "rust aarch64 linux", + input: "ripgrep-14.1.1-aarch64-unknown-linux-gnu.tar.gz", + wantOS: buildmeta.OSLinux, + arch: buildmeta.ArchARM64, + libc: buildmeta.LibcGNU, + format: buildmeta.FormatTarGz, + }, + + // Zig-style + { + name: "zig linux x86_64", + input: "zig-linux-x86_64-0.14.0.tar.xz", + wantOS: buildmeta.OSLinux, + arch: buildmeta.ArchAMD64, + format: buildmeta.FormatTarXz, + }, + { + name: "zig macos aarch64", + input: "zig-macos-aarch64-0.14.0.tar.xz", + wantOS: buildmeta.OSDarwin, + arch: buildmeta.ArchARM64, + format: buildmeta.FormatTarXz, + }, + + // Windows executables + { + name: "bare exe", + input: "jq-windows-amd64.exe", + wantOS: buildmeta.OSWindows, + arch: buildmeta.ArchAMD64, + format: buildmeta.FormatExe, + }, + { + name: "msi installer", + input: "caddy_2.9.0_windows_amd64.msi", + wantOS: buildmeta.OSWindows, + arch: buildmeta.ArchAMD64, + format: buildmeta.FormatMSI, + }, + + // macOS formats + { + name: "dmg installer", + input: "MyApp-1.0.0-darwin-arm64.dmg", + wantOS: buildmeta.OSDarwin, + arch: buildmeta.ArchARM64, + format: buildmeta.FormatDMG, + }, + + // Arch priority: x86_64 must not match x86 + { + name: "x86_64 not x86", + input: "tool-x86_64-linux.tar.gz", + wantOS: buildmeta.OSLinux, + arch: buildmeta.ArchAMD64, + format: buildmeta.FormatTarGz, + }, + { + name: "actual x86", + input: "tool-x86-linux.tar.gz", + wantOS: buildmeta.OSLinux, + arch: buildmeta.ArchX86, + format: buildmeta.FormatTarGz, + }, + { + name: "i386", + input: "tool-linux-i386.tar.gz", + wantOS: buildmeta.OSLinux, + arch: buildmeta.ArchX86, + format: buildmeta.FormatTarGz, + }, + + // ARM variants: arm64 must not match armv7/armv6 + { + name: "aarch64 not armv7", + input: "tool-aarch64-linux.tar.gz", + arch: buildmeta.ArchARM64, + }, + { + name: "armv7", + input: "tool-armv7l-linux.tar.gz", + arch: buildmeta.ArchARMv7, + }, + { + name: "armv6", + input: "tool-armv6l-linux.tar.gz", + arch: buildmeta.ArchARMv6, + }, + + // ppc64le before ppc64 + { + name: "ppc64le", + input: "tool-linux-ppc64le.tar.gz", + arch: buildmeta.ArchPPC64LE, + }, + { + name: "ppc64", + input: "tool-linux-ppc64.tar.gz", + arch: buildmeta.ArchPPC64, + }, + + // Static linking + { + name: "static binary", + input: "tool-linux-amd64-static.tar.gz", + libc: buildmeta.LibcNone, + }, + + // .exe implies Windows + { + name: "exe implies windows", + input: "tool-amd64.exe", + wantOS: buildmeta.OSWindows, + arch: buildmeta.ArchAMD64, + format: buildmeta.FormatExe, + }, + + // Compound extensions + { + name: "tar.zst", + input: "tool-linux-amd64.tar.zst", + format: buildmeta.FormatTarZst, + }, + { + name: "exe.xz", + input: "tool-windows-amd64.exe.xz", + format: buildmeta.FormatExeXz, + }, + { + name: "app.zip", + input: "MyApp-1.0.0.app.zip", + format: buildmeta.FormatAppZip, + }, + { + name: "tgz alias", + input: "tool-linux-amd64.tgz", + format: buildmeta.FormatTarGz, + }, + + // s390x, mips + { + name: "s390x", + input: "tool-linux-s390x.tar.gz", + arch: buildmeta.ArchS390X, + }, + { + name: "mips64", + input: "tool-linux-mips64.tar.gz", + arch: buildmeta.ArchMIPS64, + }, + + // Unknown / no match + { + name: "checksum file", + input: "checksums.txt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classify.Filename(tt.input) + if tt.wantOS != "" && got.OS != tt.wantOS { + t.Errorf("OS = %q, want %q", got.OS, tt.wantOS) + } + if tt.arch != "" && got.Arch != tt.arch { + t.Errorf("Arch = %q, want %q", got.Arch, tt.arch) + } + if tt.libc != "" && got.Libc != tt.libc { + t.Errorf("Libc = %q, want %q", got.Libc, tt.libc) + } + if tt.format != "" && got.Format != tt.format { + t.Errorf("Format = %q, want %q", got.Format, tt.format) + } + }) + } +} diff --git a/internal/platlatest/platlatest.go b/internal/platlatest/platlatest.go new file mode 100644 index 0000000..7e28d90 --- /dev/null +++ b/internal/platlatest/platlatest.go @@ -0,0 +1,104 @@ +// Package platlatest tracks the newest release version per build target. +// +// After classification determines which OS/arch/libc targets a release +// covers, this package records the latest version for each target. This +// handles the common case where Windows or macOS releases lag behind +// Linux by several versions. +// +// Storage is a single JSON file per package: +// +// { +// "linux-x86_64-gnu": "v0.145.0", +// "darwin-aarch64-none": "v0.144.1", +// "windows-x86_64-msvc": "v0.143.0" +// } +package platlatest + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/webinstall/webi-installers/internal/buildmeta" +) + +// Index tracks the latest version for each build target of a package. +type Index struct { + mu sync.RWMutex + path string + m map[string]string // triplet → version +} + +// Open loads or creates a per-platform latest index at the given path. +func Open(path string) (*Index, error) { + idx := &Index{ + path: path, + m: make(map[string]string), + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return idx, nil + } + return nil, fmt.Errorf("platlatest: read %s: %w", path, err) + } + + if err := json.Unmarshal(data, &idx.m); err != nil { + return nil, fmt.Errorf("platlatest: parse %s: %w", path, err) + } + return idx, nil +} + +// Get returns the latest version for a target, or "" if unknown. +func (idx *Index) Get(t buildmeta.Target) string { + idx.mu.RLock() + defer idx.mu.RUnlock() + return idx.m[t.Triplet()] +} + +// Set records a version as the latest for a target. Does not persist +// to disk — call Save after all updates. +func (idx *Index) Set(t buildmeta.Target, version string) { + idx.mu.Lock() + defer idx.mu.Unlock() + idx.m[t.Triplet()] = version +} + +// All returns a copy of the full triplet→version map. +func (idx *Index) All() map[string]string { + idx.mu.RLock() + defer idx.mu.RUnlock() + out := make(map[string]string, len(idx.m)) + for k, v := range idx.m { + out[k] = v + } + return out +} + +// Save persists the index to disk (atomic write). +func (idx *Index) Save() error { + idx.mu.RLock() + data, err := json.MarshalIndent(idx.m, "", " ") + idx.mu.RUnlock() + if err != nil { + return fmt.Errorf("platlatest: marshal: %w", err) + } + + dir := filepath.Dir(idx.path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("platlatest: mkdir: %w", err) + } + + tmp := idx.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return fmt.Errorf("platlatest: write %s: %w", tmp, err) + } + if err := os.Rename(tmp, idx.path); err != nil { + os.Remove(tmp) + return fmt.Errorf("platlatest: rename %s: %w", idx.path, err) + } + return nil +} diff --git a/internal/platlatest/platlatest_test.go b/internal/platlatest/platlatest_test.go new file mode 100644 index 0000000..82f2539 --- /dev/null +++ b/internal/platlatest/platlatest_test.go @@ -0,0 +1,104 @@ +package platlatest_test + +import ( + "path/filepath" + "testing" + + "github.com/webinstall/webi-installers/internal/buildmeta" + "github.com/webinstall/webi-installers/internal/platlatest" +) + +var ( + linuxAMD64 = buildmeta.Target{ + OS: buildmeta.OSLinux, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcGNU, + } + darwinARM64 = buildmeta.Target{ + OS: buildmeta.OSDarwin, Arch: buildmeta.ArchARM64, Libc: buildmeta.LibcNone, + } + windowsAMD64 = buildmeta.Target{ + OS: buildmeta.OSWindows, Arch: buildmeta.ArchAMD64, Libc: buildmeta.LibcMSVC, + } +) + +func TestSetAndGet(t *testing.T) { + p := filepath.Join(t.TempDir(), "latest.json") + idx, err := platlatest.Open(p) + if err != nil { + t.Fatal(err) + } + + if got := idx.Get(linuxAMD64); got != "" { + t.Errorf("Get before Set = %q, want empty", got) + } + + idx.Set(linuxAMD64, "v0.145.0") + idx.Set(darwinARM64, "v0.144.1") + idx.Set(windowsAMD64, "v0.143.0") + + if got := idx.Get(linuxAMD64); got != "v0.145.0" { + t.Errorf("linux = %q, want v0.145.0", got) + } + if got := idx.Get(darwinARM64); got != "v0.144.1" { + t.Errorf("darwin = %q, want v0.144.1", got) + } + if got := idx.Get(windowsAMD64); got != "v0.143.0" { + t.Errorf("windows = %q, want v0.143.0", got) + } +} + +func TestSaveAndReload(t *testing.T) { + p := filepath.Join(t.TempDir(), "latest.json") + + idx1, err := platlatest.Open(p) + if err != nil { + t.Fatal(err) + } + idx1.Set(linuxAMD64, "v0.145.0") + idx1.Set(darwinARM64, "v0.144.1") + if err := idx1.Save(); err != nil { + t.Fatal(err) + } + + // Reload from disk. + idx2, err := platlatest.Open(p) + if err != nil { + t.Fatal(err) + } + if got := idx2.Get(linuxAMD64); got != "v0.145.0" { + t.Errorf("after reload: linux = %q, want v0.145.0", got) + } + if got := idx2.Get(darwinARM64); got != "v0.144.1" { + t.Errorf("after reload: darwin = %q, want v0.144.1", got) + } +} + +func TestAll(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") + idx.Set(darwinARM64, "v0.9.0") + + all := idx.All() + if len(all) != 2 { + t.Fatalf("All() returned %d entries, want 2", len(all)) + } + if all[linuxAMD64.Triplet()] != "v1.0.0" { + t.Error("missing linux entry") + } +} + +func TestOpenNonexistent(t *testing.T) { + p := filepath.Join(t.TempDir(), "does-not-exist.json") + idx, err := platlatest.Open(p) + if err != nil { + t.Fatal(err) + } + // Should be empty, not nil. + if all := idx.All(); len(all) != 0 { + t.Errorf("new index should be empty, got %v", all) + } +}