feat(uadetect): add FromRequest for full agent detection

The user agent identifies itself through multiple signals — the
User-Agent header and query parameters (?os, ?arch). FromRequest
unifies both, with explicit query params taking precedence.
This commit is contained in:
AJ ONeal
2026-03-08 22:19:04 -06:00
parent 43ab591061
commit 4f3bdd7d58
2 changed files with 85 additions and 7 deletions

View File

@@ -1,15 +1,19 @@
// Package uadetect identifies the requesting system's OS, CPU architecture,
// and libc from its User-Agent string.
// Package uadetect identifies the requesting agent's OS, CPU architecture,
// and libc so the server can select the correct release artifact.
//
// Webi's bootstrap scripts send "$(uname -srm)" as the User-Agent, e.g.
// "Darwin 23.1.0 arm64" or "Linux 6.1.0 x86_64". This package parses those
// into [buildmeta.OS], [buildmeta.Arch], and [buildmeta.Libc] values so the
// server can select the correct release artifact.
// An agent identifies itself through multiple signals:
// - The User-Agent header: Webi's bootstrap scripts send "$(uname -srm)",
// e.g. "Darwin 23.1.0 arm64". Browsers, curl, and PowerShell send their
// own UA strings.
// - Query parameters: ?os=linux&arch=arm64 are an explicit declaration
// that takes precedence over the header.
//
// Also handles non-uname agents like "PowerShell/7.3.0" and "MS AMD64".
// Use [FromRequest] to detect from an HTTP request (preferred).
// Use [Parse] to detect from a raw UA string.
package uadetect
import (
"net/http"
"strings"
"github.com/webinstall/webi-installers/internal/buildmeta"
@@ -22,6 +26,27 @@ type Result struct {
Libc buildmeta.Libc
}
// FromRequest detects the agent's platform from an HTTP request.
// Query parameters ?os and ?arch override the User-Agent header.
func FromRequest(r *http.Request) Result {
qOS := r.URL.Query().Get("os")
qArch := r.URL.Query().Get("arch")
var ua string
switch {
case qOS != "" && qArch != "":
ua = qOS + " " + qArch
case qOS != "":
ua = qOS
case qArch != "":
ua = qArch
default:
ua = r.Header.Get("User-Agent")
}
return Parse(ua)
}
// Parse extracts OS, arch, and libc from a User-Agent string.
func Parse(ua string) Result {
if ua == "-" {

View File

@@ -1,6 +1,7 @@
package uadetect_test
import (
"net/http"
"testing"
"github.com/webinstall/webi-installers/internal/buildmeta"
@@ -103,6 +104,58 @@ func TestLibc(t *testing.T) {
}
}
func TestFromRequest(t *testing.T) {
tests := []struct {
name string
ua string // User-Agent header
query string // raw query string
wantOS buildmeta.OS
wantAr buildmeta.Arch
}{
{
name: "UA header only",
ua: "Darwin 23.1.0 arm64",
wantOS: buildmeta.OSDarwin,
wantAr: buildmeta.ArchARM64,
},
{
name: "query params override UA",
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
query: "os=linux&arch=aarch64",
wantOS: buildmeta.OSLinux,
wantAr: buildmeta.ArchARM64,
},
{
name: "os param only",
ua: "curl/8.1.2",
query: "os=windows",
wantOS: buildmeta.OSWindows,
},
{
name: "arch param only",
ua: "curl/8.1.2",
query: "arch=arm64",
wantAr: buildmeta.ArchARM64,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest("GET", "http://example.com/api?"+tt.query, nil)
if tt.ua != "" {
req.Header.Set("User-Agent", tt.ua)
}
got := uadetect.FromRequest(req)
if tt.wantOS != "" && got.OS != tt.wantOS {
t.Errorf("OS = %q, want %q", got.OS, tt.wantOS)
}
if tt.wantAr != "" && got.Arch != tt.wantAr {
t.Errorf("Arch = %q, want %q", got.Arch, tt.wantAr)
}
})
}
}
func TestFullParse(t *testing.T) {
r := uadetect.Parse("Darwin 23.1.0 arm64")
if r.OS != buildmeta.OSDarwin {