feat: add Phase 0 foundation packages for Go rewrite

- internal/buildmeta: canonical constants for OS, arch, libc, format, channel
- internal/lexver: version string → lexicographically sortable string
- internal/uadetect: User-Agent → OS/arch/libc detection
- internal/httpclient: resilient net/http client with retry and backoff
- go.mod: initialize module (stdlib only, no dependencies)
This commit is contained in:
AJ ONeal
2026-03-08 21:38:43 -06:00
parent 0acc6b06aa
commit cf9dd4d2e2
7 changed files with 993 additions and 0 deletions

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/webinstall/webi-installers
go 1.24

View File

@@ -0,0 +1,131 @@
// Package buildmeta defines the canonical constants for OS, architecture,
// libc, archive format, and release channel used throughout Webi.
package buildmeta
// OS represents a target operating system.
type OS string
const (
OSAny OS = "ANYOS"
OSDarwin OS = "darwin"
OSLinux OS = "linux"
OSWindows OS = "windows"
OSFreeBSD OS = "freebsd"
OSSunOS OS = "sunos"
OSAIX OS = "aix"
OSAndroid OS = "android"
// POSIX compatibility levels — used when a package is a shell script
// or otherwise OS-independent for POSIX systems.
OSPosix2017 OS = "posix_2017"
OSPosix2024 OS = "posix_2024"
)
// Arch represents a target CPU architecture.
type Arch string
const (
ArchAny Arch = "ANYARCH"
ArchAMD64 Arch = "x86_64"
ArchARM64 Arch = "aarch64"
ArchARMv7 Arch = "armv7"
ArchARMv6 Arch = "armv6"
ArchX86 Arch = "x86"
ArchPPC64LE Arch = "ppc64le"
ArchPPC64 Arch = "ppc64"
ArchS390X Arch = "s390x"
ArchMIPS64 Arch = "mips64"
ArchMIPS Arch = "mips"
)
// Libc represents the C library a binary is linked against.
type Libc string
const (
LibcNone Libc = "none" // statically linked or no libc dependency (Go, Zig, etc.)
LibcGNU Libc = "gnu" // requires glibc (most Linux distros)
LibcMusl Libc = "musl" // requires musl (Alpine, some Docker images)
LibcMSVC Libc = "msvc" // Microsoft Visual C++ runtime
)
// Format represents an archive or package format.
type Format string
const (
FormatTarGz Format = ".tar.gz"
FormatTarXz Format = ".tar.xz"
FormatTarZst Format = ".tar.zst"
FormatZip Format = ".zip"
FormatGz Format = ".gz"
FormatXz Format = ".xz"
FormatZst Format = ".zst"
FormatExe Format = ".exe"
FormatExeXz Format = ".exe.xz"
FormatMSI Format = ".msi"
FormatDMG Format = ".dmg"
FormatPkg Format = ".pkg"
FormatAppZip Format = ".app.zip"
Format7z Format = ".7z"
FormatSh Format = ".sh"
FormatGit Format = ".git"
)
// Channel represents a release stability channel.
type Channel string
const (
ChannelStable Channel = "stable"
ChannelLatest Channel = "latest"
ChannelRC Channel = "rc"
ChannelPreview Channel = "preview"
ChannelBeta Channel = "beta"
ChannelAlpha Channel = "alpha"
ChannelDev Channel = "dev"
)
// ChannelNames lists recognized channel names in priority order.
var ChannelNames = []Channel{
ChannelStable,
ChannelLatest,
ChannelRC,
ChannelPreview,
ChannelBeta,
ChannelAlpha,
ChannelDev,
}
// Target represents a fully resolved build target.
type Target struct {
OS OS
Arch Arch
Libc Libc
}
// Triplet returns the canonical "os-arch-libc" string.
func (t Target) Triplet() string {
return string(t.OS) + "-" + string(t.Arch) + "-" + string(t.Libc)
}
// Release represents a single downloadable build artifact.
type Release struct {
Name string `json:"name"`
Version string `json:"version"`
LTS bool `json:"lts"`
Channel Channel `json:"channel"`
Date string `json:"date"` // "2024-01-15"
OS OS `json:"os"`
Arch Arch `json:"arch"`
Libc Libc `json:"libc"`
Ext Format `json:"ext"`
Download string `json:"download"`
Comment string `json:"comment,omitempty"`
}
// PackageMeta holds aggregate metadata about a package's available releases.
type PackageMeta struct {
Name string `json:"name"`
OSes []OS `json:"oses"`
Arches []Arch `json:"arches"`
Libcs []Libc `json:"libcs"`
Formats []Format `json:"formats"`
}

View File

@@ -0,0 +1,203 @@
// Package httpclient provides a resilient HTTP client with best-practice
// defaults for making upstream API calls (GitHub, Gitea, etc.).
//
// Features:
// - Sensible timeouts at every level (connect, TLS, headers, overall)
// - Connection pooling with reasonable limits
// - TLS 1.2+ minimum
// - Limited redirect depth, no HTTPS→HTTP downgrade
// - Automatic retries with exponential backoff + jitter for transient errors
// - Respects Retry-After headers
// - Custom User-Agent identifying Webi
// - All calls require context.Context
package httpclient
import (
"context"
"crypto/tls"
"errors"
"fmt"
"math/rand/v2"
"net"
"net/http"
"strconv"
"time"
)
// Client wraps http.Client with retry logic and resilience defaults.
type Client struct {
inner *http.Client
userAgent string
retries int
baseDelay time.Duration
maxDelay time.Duration
}
// Option configures a Client.
type Option func(*Client)
// WithUserAgent sets the User-Agent header for all requests.
func WithUserAgent(ua string) Option {
return func(c *Client) { c.userAgent = ua }
}
// WithRetries sets the maximum number of retries for transient errors.
func WithRetries(n int) Option {
return func(c *Client) { c.retries = n }
}
// WithBaseDelay sets the initial delay for exponential backoff.
func WithBaseDelay(d time.Duration) Option {
return func(c *Client) { c.baseDelay = d }
}
// New creates a Client with secure, resilient defaults.
func New(opts ...Option) *Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: true,
}
c := &Client{
inner: &http.Client{
Transport: transport,
Timeout: 60 * time.Second,
CheckRedirect: checkRedirect,
},
userAgent: "Webi/2.0 (+https://webinstall.dev)",
retries: 3,
baseDelay: 1 * time.Second,
maxDelay: 30 * time.Second,
}
for _, opt := range opts {
opt(c)
}
return c
}
// maxRedirects is the redirect depth limit.
const maxRedirects = 10
// checkRedirect prevents HTTPS→HTTP downgrades and limits redirect depth.
func checkRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("stopped after %d redirects", maxRedirects)
}
if len(via) > 0 && via[0].URL.Scheme == "https" && req.URL.Scheme == "http" {
return errors.New("refused redirect from https to http")
}
return nil
}
// Do executes a request with automatic retries for transient errors.
// It sets the User-Agent header if not already present.
func (c *Client) Do(req *http.Request) (*http.Response, error) {
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", c.userAgent)
}
var resp *http.Response
var err error
for attempt := 0; attempt <= c.retries; attempt++ {
if attempt > 0 {
delay := c.backoff(attempt, resp)
select {
case <-req.Context().Done():
return nil, req.Context().Err()
case <-time.After(delay):
}
// Close previous response body before retry.
if resp != nil {
resp.Body.Close()
}
}
resp, err = c.inner.Do(req)
if err != nil {
// Retry on transient network errors.
if req.Context().Err() != nil {
return nil, req.Context().Err()
}
continue
}
if !isRetryable(resp.StatusCode) {
return resp, nil
}
}
// Exhausted retries — return whatever we have.
if err != nil {
return nil, fmt.Errorf("after %d retries: %w", c.retries, err)
}
return resp, nil
}
// Get is a convenience wrapper around Do for GET requests.
func (c *Client) Get(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return c.Do(req)
}
// isRetryable returns true for HTTP status codes that indicate a transient error.
func isRetryable(status int) bool {
switch status {
case http.StatusTooManyRequests, // 429
http.StatusBadGateway, // 502
http.StatusServiceUnavailable, // 503
http.StatusGatewayTimeout: // 504
return true
}
return false
}
// backoff calculates the delay before the next retry attempt.
// Uses exponential backoff with jitter, and respects Retry-After headers.
func (c *Client) backoff(attempt int, resp *http.Response) time.Duration {
// Check Retry-After header from previous response.
if resp != nil {
if ra := resp.Header.Get("Retry-After"); ra != "" {
if seconds, err := strconv.Atoi(ra); err == nil {
d := time.Duration(seconds) * time.Second
if d > 0 && d < 5*time.Minute {
return d
}
}
}
}
// Exponential backoff: baseDelay * 2^attempt + jitter
delay := c.baseDelay
for i := 1; i < attempt; i++ {
delay *= 2
if delay > c.maxDelay {
delay = c.maxDelay
break
}
}
// Add jitter: ±25%
jitter := time.Duration(float64(delay) * 0.5 * rand.Float64())
delay = delay + jitter - (delay / 4)
return delay
}

223
internal/lexver/lexver.go Normal file
View File

@@ -0,0 +1,223 @@
// Package lexver converts version strings into lexicographically sortable
// representations so that version comparison reduces to string comparison.
//
// The core problem: "1.20.3" must sort after "1.2.0", but as raw strings
// "1.2" > "1.20" because '2' > '.' in ASCII. Lexver solves this by
// zero-padding each numeric segment to a fixed width.
//
// Sorting rules:
// - Numeric segments are zero-padded and compared naturally
// - Stable releases sort after pre-releases of the same version
// - Pre-release channels sort alphabetically (alpha < beta < rc)
// - Numeric suffixes within channels sort numerically (rc2 > rc1)
//
// Examples:
//
// "1.20.3" → "0001.0020.0003.0000~"
// "1.0.0-beta1" → "0001.0000.0000.0000-beta.0001"
// "1.0.0" → "0001.0000.0000.0000~"
//
// The "~" character sorts after "-" in ASCII, so stable versions always
// sort after any pre-release of the same numeric version.
package lexver
import (
"strconv"
"strings"
"unicode"
)
const (
numWidth = 4 // zero-pad width for version numbers
chanNumWidth = 4 // zero-pad width for channel sequence numbers
numSegments = 4 // major.minor.patch.build
// suffixStable sorts after suffixPre because '~' > '-' in ASCII.
suffixStable = "~"
suffixPre = "-"
)
// Parse converts a version string to its lexicographically sortable form.
func Parse(version string) string {
return format(splitVersion(version), false)
}
// ParsePrefix converts a partial version to a sortable prefix for matching.
// Unlike Parse, it does not pad to the full segment count.
//
// ParsePrefix("1.20") → "0001.0020"
func ParsePrefix(version string) string {
return format(splitVersion(version), true)
}
// versionParts holds the parsed components of a version string.
type versionParts struct {
nums []int // numeric segments: [1, 20, 3, 0]
channel string // pre-release channel: "beta", "rc", "" for stable
chanNum int // pre-release sequence: 1 in "beta1", 0 if absent
}
// splitVersion breaks a version string into its semantic components.
func splitVersion(version string) versionParts {
// Strip leading "v" or "V"
version = strings.TrimLeft(version, "vV")
var p versionParts
// Find where the pre-release suffix begins.
// We look for the first letter after the numeric prefix.
numStr, prerelease := splitAtPrerelease(version)
// Parse numeric segments
for _, seg := range strings.Split(numStr, ".") {
if seg == "" {
continue
}
n, err := strconv.Atoi(seg)
if err != nil {
// If we hit a non-numeric segment in the numeric part,
// treat it as start of prerelease.
if prerelease == "" {
prerelease = seg
} else {
prerelease = seg + "-" + prerelease
}
continue
}
p.nums = append(p.nums, n)
}
// Parse pre-release: "beta1" → channel="beta", chanNum=1
if prerelease != "" {
p.channel, p.chanNum = splitChannel(prerelease)
}
return p
}
// splitAtPrerelease splits "1.20.3-beta1" into ("1.20.3", "beta1").
// Also handles "1.2beta3" (no separator before channel name).
func splitAtPrerelease(s string) (string, string) {
// Try explicit separator first: dash, plus
for _, sep := range []byte{'-', '+'} {
if idx := strings.IndexByte(s, sep); idx >= 0 {
return s[:idx], s[idx+1:]
}
}
// Look for a letter following a digit: "1.2beta3"
for i := 1; i < len(s); i++ {
if unicode.IsLetter(rune(s[i])) && unicode.IsDigit(rune(s[i-1])) {
return s[:i], s[i:]
}
}
return s, ""
}
// splitChannel separates "beta1" into ("beta", 1) or "rc" into ("rc", 0).
func splitChannel(s string) (string, int) {
s = strings.ToLower(s)
// Normalize separators: "beta-1", "beta.1" → "beta1"
s = strings.NewReplacer("-", "", ".", "", "_", "").Replace(s)
// Find where trailing digits begin
i := len(s)
for i > 0 && unicode.IsDigit(rune(s[i-1])) {
i--
}
name := s[:i]
num := 0
if i < len(s) {
num, _ = strconv.Atoi(s[i:])
}
return name, num
}
// format renders parsed version parts into a lexver string.
func format(p versionParts, asPrefix bool) string {
// Pad numeric segments
count := len(p.nums)
if !asPrefix && count < numSegments {
count = numSegments
}
var b strings.Builder
b.Grow(count*5 + 20) // rough estimate
for i := 0; i < count; i++ {
if i > 0 {
b.WriteByte('.')
}
n := 0
if i < len(p.nums) {
n = p.nums[i]
}
b.WriteString(padInt(n, numWidth))
}
// Append stability suffix
if p.channel == "" {
b.WriteString(suffixStable)
} else {
b.WriteString(suffixPre)
b.WriteString(p.channel)
b.WriteByte('.')
b.WriteString(padInt(p.chanNum, chanNumWidth))
}
return b.String()
}
func padInt(n, width int) string {
s := strconv.Itoa(n)
for len(s) < width {
s = "0" + s
}
return s
}
// IsPreRelease reports whether version looks like a pre-release.
func IsPreRelease(version string) bool {
p := splitVersion(version)
return p.channel != ""
}
// Match holds the result of searching a sorted lexver list.
type Match struct {
// Latest is the newest version regardless of channel.
Latest string
// Stable is the newest stable (non-pre-release) version.
Stable string
// Default is Stable if available, otherwise Latest.
Default string
// Matches lists all lexvers matching the prefix, newest first.
Matches []string
}
// MatchSorted searches a descending-sorted slice of lexvers for entries
// matching the given prefix. If prefix is empty, all versions match.
func MatchSorted(lexvers []string, prefix string) Match {
var m Match
for _, lv := range lexvers {
if prefix != "" && !strings.HasPrefix(lv, prefix) {
continue
}
m.Matches = append(m.Matches, lv)
if m.Latest == "" {
m.Latest = lv
}
if m.Stable == "" && strings.HasSuffix(lv, suffixStable) {
m.Stable = lv
}
}
if m.Stable != "" {
m.Default = m.Stable
} else {
m.Default = m.Latest
}
return m
}

View File

@@ -0,0 +1,155 @@
package lexver_test
import (
"testing"
"github.com/webinstall/webi-installers/internal/lexver"
)
func TestParse(t *testing.T) {
tests := []struct {
input string
want string
}{
// Basic semver
{"1.0.0", "0001.0000.0000.0000~"},
{"1.2.3", "0001.0002.0003.0000~"},
{"0.1.0", "0000.0001.0000.0000~"},
// Leading v
{"v1.2.3", "0001.0002.0003.0000~"},
{"V1.0.0", "0001.0000.0000.0000~"},
// Partial versions (padded to 4 segments)
{"1.20", "0001.0020.0000.0000~"},
{"1", "0001.0000.0000.0000~"},
// Large numbers
{"1.20.156", "0001.0020.0156.0000~"},
// Pre-release channels
{"1.0.0-beta1", "0001.0000.0000.0000-beta.0001"},
{"1.0.0-rc2", "0001.0000.0000.0000-rc.0002"},
{"1.0.0-alpha3", "0001.0000.0000.0000-alpha.0003"},
{"2.0.0-preview1", "0002.0000.0000.0000-preview.0001"},
{"1.0.0-dev", "0001.0000.0000.0000-dev.0000"},
// Channel attached to number (no separator)
{"1.2beta3", "0001.0002.0000.0000-beta.0003"},
{"1.0rc1", "0001.0000.0000.0000-rc.0001"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := lexver.Parse(tt.input)
if got != tt.want {
t.Errorf("Parse(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestParsePrefix(t *testing.T) {
tests := []struct {
input string
want string
}{
{"1.20", "0001.0020~"},
{"1", "0001~"},
{"v2", "0002~"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := lexver.ParsePrefix(tt.input)
if got != tt.want {
t.Errorf("ParsePrefix(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestSortOrder(t *testing.T) {
// These must produce ascending lexver strings.
ordered := []string{
"0.1.0",
"1.0.0-alpha1",
"1.0.0-beta1",
"1.0.0-rc1",
"1.0.0-rc2",
"1.0.0",
"1.0.1",
"1.1.0",
"1.2.0",
"1.20.0",
"2.0.0-beta1",
"2.0.0",
}
for i := 1; i < len(ordered); i++ {
prev := lexver.Parse(ordered[i-1])
curr := lexver.Parse(ordered[i])
if prev >= curr {
t.Errorf("expected Parse(%q) < Parse(%q)\n got %q >= %q",
ordered[i-1], ordered[i], prev, curr)
}
}
}
func TestIsPreRelease(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"1.0.0", false},
{"1.0.0-beta1", true},
{"1.0.0-rc2", true},
{"1.0.0-alpha", true},
{"1.0.0-dev", true},
{"v2.0.0-preview1", true},
{"1.0.0-pre1", true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := lexver.IsPreRelease(tt.input)
if got != tt.want {
t.Errorf("IsPreRelease(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestMatchSorted(t *testing.T) {
// Descending order (as stored)
lexvers := []string{
lexver.Parse("2.0.0"),
lexver.Parse("2.0.0-rc1"),
lexver.Parse("1.20.3"),
lexver.Parse("1.20.2"),
lexver.Parse("1.19.5"),
}
t.Run("empty prefix matches all", func(t *testing.T) {
m := lexver.MatchSorted(lexvers, "")
if len(m.Matches) != len(lexvers) {
t.Errorf("expected %d matches, got %d", len(lexvers), len(m.Matches))
}
if m.Latest != lexvers[0] {
t.Errorf("Latest = %q, want %q", m.Latest, lexvers[0])
}
if m.Stable != lexvers[0] {
t.Errorf("Stable = %q, want %q", m.Stable, lexvers[0])
}
})
t.Run("prefix filters versions", func(t *testing.T) {
prefix := lexver.ParsePrefix("1.20")
// Strip the "~" suffix for prefix matching
prefix = prefix[:len(prefix)-1]
m := lexver.MatchSorted(lexvers, prefix)
if len(m.Matches) != 2 {
t.Errorf("expected 2 matches for 1.20.x, got %d: %v", len(m.Matches), m.Matches)
}
})
}

View File

@@ -0,0 +1,161 @@
// Package uadetect identifies OS, architecture, and libc from a User-Agent
// string. The input is typically from curl's -A flag:
//
// curl -fsSA "$(uname -srm)" https://webi.sh/node
//
// Which produces something like:
//
// "Darwin 23.1.0 arm64"
// "Linux 6.1.0 x86_64"
// "CYGWIN_NT-10.0-19045 3.5.3 x86_64"
package uadetect
import (
"regexp"
"strings"
"github.com/webinstall/webi-installers/internal/buildmeta"
)
// Result holds the detected platform info from a User-Agent string.
type Result struct {
OS buildmeta.OS
Arch buildmeta.Arch
Libc buildmeta.Libc
}
// Parse extracts OS, arch, and libc from a User-Agent string.
func Parse(ua string) Result {
return Result{
OS: DetectOS(ua),
Arch: DetectArch(ua),
Libc: DetectLibc(ua),
}
}
// DetectOS returns the OS from a User-Agent string.
func DetectOS(ua string) buildmeta.OS {
if ua == "-" {
return ""
}
// Android must be tested before Linux.
if reAndroid.MatchString(ua) {
return buildmeta.OSAndroid
}
// macOS/Darwin must be tested before Linux (for edge cases) and before
// "win" (because "darwin" contains no "win", but ordering matters).
if reDarwin.MatchString(ua) {
return buildmeta.OSDarwin
}
// Linux must be tested before Windows because WSL User-Agents contain
// both "Linux" and sometimes "Microsoft".
if reLinux.MatchString(ua) && !reCygwin.MatchString(ua) {
return buildmeta.OSLinux
}
if reWindows.MatchString(ua) {
return buildmeta.OSWindows
}
// Try Linux again after Windows (for plain "curl" or "wget").
if reLinuxLoose.MatchString(ua) {
return buildmeta.OSLinux
}
return ""
}
// DetectArch returns the CPU architecture from a User-Agent string.
func DetectArch(ua string) buildmeta.Arch {
if ua == "-" {
return ""
}
// Strip macOS kernel release arch info that can mislead detection.
// e.g. "xnu-7195.60.75~1/RELEASE_ARM64_T8101 x86_64" under Rosetta
ua = reXNU.ReplaceAllString(ua, "")
// Order matters — more specific patterns first.
if reARM64.MatchString(ua) {
return buildmeta.ArchARM64
}
if reARMv7.MatchString(ua) {
return buildmeta.ArchARMv7
}
if reARMv6.MatchString(ua) {
return buildmeta.ArchARMv6
}
if rePPC64LE.MatchString(ua) {
return buildmeta.ArchPPC64LE
}
if rePPC64.MatchString(ua) {
return buildmeta.ArchPPC64
}
if reMIPS64.MatchString(ua) {
return buildmeta.ArchMIPS64
}
if reMIPS.MatchString(ua) {
return buildmeta.ArchMIPS
}
// amd64 must come after ppc64/mips64 (both contain "64").
if reAMD64.MatchString(ua) {
return buildmeta.ArchAMD64
}
// x86 must come after x86_64/amd64.
if reX86.MatchString(ua) {
return buildmeta.ArchX86
}
return ""
}
// DetectLibc returns the C library variant from a User-Agent string.
func DetectLibc(ua string) buildmeta.Libc {
if ua == "-" {
return ""
}
lower := strings.ToLower(ua)
if reMusl.MatchString(lower) {
return buildmeta.LibcMusl
}
if reMSVC.MatchString(lower) {
return buildmeta.LibcMSVC
}
if reGNU.MatchString(lower) {
return buildmeta.LibcGNU
}
// Default: no specific libc requirement detected.
return buildmeta.LibcNone
}
// Compiled regexes — allocated once.
var (
reAndroid = regexp.MustCompile(`(?i)Android`)
reDarwin = regexp.MustCompile(`(?i)iOS|iPhone|Macintosh|Darwin|OS\s*X|macOS|mac`)
reLinux = regexp.MustCompile(`(?i)Linux`)
reCygwin = regexp.MustCompile(`(?i)cygwin|msysgit`)
reWindows = regexp.MustCompile(`(?i)(\b|^)ms(\b|$)|Microsoft|Windows|win32|win|PowerShell`)
reLinuxLoose = regexp.MustCompile(`(?i)Linux|curl|wget`)
reXNU = regexp.MustCompile(`xnu-\S*RELEASE_\S*`)
reARM64 = regexp.MustCompile(`(?i)(\b|_)(aarch64|arm64|arm8|armv8)`)
reARMv7 = regexp.MustCompile(`(?i)(\b|_)(aarch|arm7|armv7|arm32)`)
reARMv6 = regexp.MustCompile(`(?i)(\b|_)(arm6|armv6|arm(\b|_))`)
rePPC64LE = regexp.MustCompile(`(?i)ppc64le`)
rePPC64 = regexp.MustCompile(`(?i)ppc64`)
reMIPS64 = regexp.MustCompile(`(?i)mips64`)
reMIPS = regexp.MustCompile(`(?i)mips`)
reAMD64 = regexp.MustCompile(`(?i)(amd64|x86_64|x64|_64)\b`)
reX86 = regexp.MustCompile(`(?i)(\b|_)(3|6|x|_)86\b`)
reMusl = regexp.MustCompile(`(\b|_)musl(\b|_)`)
reMSVC = regexp.MustCompile(`(\b|_)(msvc|windows|microsoft)(\b|_)`)
reGNU = regexp.MustCompile(`(\b|_)(gnu|glibc|linux)(\b|_)`)
)

View File

@@ -0,0 +1,117 @@
package uadetect_test
import (
"testing"
"github.com/webinstall/webi-installers/internal/buildmeta"
"github.com/webinstall/webi-installers/internal/uadetect"
)
func TestDetectOS(t *testing.T) {
tests := []struct {
ua string
want buildmeta.OS
}{
// macOS / Darwin
{"Darwin 23.1.0 arm64", buildmeta.OSDarwin},
{"Darwin 20.2.0 x86_64", buildmeta.OSDarwin},
{"Macintosh; Intel Mac OS X 10_15_7", buildmeta.OSDarwin},
// Linux
{"Linux 6.1.0-18-amd64 x86_64", buildmeta.OSLinux},
{"Linux 5.15.0 aarch64", buildmeta.OSLinux},
// WSL (Linux, not Windows)
{"Linux 5.15.146.1-microsoft-standard-WSL2 x86_64", buildmeta.OSLinux},
// Windows
{"MS AMD64", buildmeta.OSWindows},
{"PowerShell/7.3.0", buildmeta.OSWindows},
{"Microsoft Windows 10.0.19045", buildmeta.OSWindows},
// Android
{"Android 13 aarch64", buildmeta.OSAndroid},
// Minimal agents
{"curl/8.1.2", buildmeta.OSLinux},
{"wget/1.21", buildmeta.OSLinux},
// Dash means unknown
{"-", ""},
}
for _, tt := range tests {
t.Run(tt.ua, func(t *testing.T) {
got := uadetect.DetectOS(tt.ua)
if got != tt.want {
t.Errorf("DetectOS(%q) = %q, want %q", tt.ua, got, tt.want)
}
})
}
}
func TestDetectArch(t *testing.T) {
tests := []struct {
ua string
want buildmeta.Arch
}{
{"Darwin 23.1.0 arm64", buildmeta.ArchARM64},
{"Linux 6.1.0 aarch64", buildmeta.ArchARM64},
{"Linux 5.4.0 x86_64", buildmeta.ArchAMD64},
{"MS AMD64", buildmeta.ArchAMD64},
{"Linux 5.10.0 armv7l", buildmeta.ArchARMv7},
{"Linux 5.10.0 armv6l", buildmeta.ArchARMv6},
{"Linux 5.4.0 ppc64le", buildmeta.ArchPPC64LE},
// Rosetta: kernel says ARM64 but uname reports x86_64
{"Darwin 20.2.0 Darwin Kernel Version 20.2.0; root:xnu-7195.60.75~1/RELEASE_ARM64_T8101 x86_64", buildmeta.ArchAMD64},
{"-", ""},
}
for _, tt := range tests {
t.Run(tt.ua, func(t *testing.T) {
got := uadetect.DetectArch(tt.ua)
if got != tt.want {
t.Errorf("DetectArch(%q) = %q, want %q", tt.ua, got, tt.want)
}
})
}
}
func TestDetectLibc(t *testing.T) {
tests := []struct {
ua string
want buildmeta.Libc
}{
{"Linux 6.1.0 x86_64 musl", buildmeta.LibcMusl},
{"Linux 6.1.0 x86_64 gnu", buildmeta.LibcGNU},
{"Linux 6.1.0 x86_64 linux", buildmeta.LibcGNU},
{"MS AMD64 msvc", buildmeta.LibcMSVC},
{"Microsoft Windows", buildmeta.LibcMSVC},
{"Darwin 23.1.0 arm64", buildmeta.LibcNone},
{"-", ""},
}
for _, tt := range tests {
t.Run(tt.ua, func(t *testing.T) {
got := uadetect.DetectLibc(tt.ua)
if got != tt.want {
t.Errorf("DetectLibc(%q) = %q, want %q", tt.ua, got, tt.want)
}
})
}
}
func TestParse(t *testing.T) {
r := uadetect.Parse("Darwin 23.1.0 arm64")
if r.OS != buildmeta.OSDarwin {
t.Errorf("OS = %q, want %q", r.OS, buildmeta.OSDarwin)
}
if r.Arch != buildmeta.ArchARM64 {
t.Errorf("Arch = %q, want %q", r.Arch, buildmeta.ArchARM64)
}
if r.Libc != buildmeta.LibcNone {
t.Errorf("Libc = %q, want %q", r.Libc, buildmeta.LibcNone)
}
}