First slice of the `banger update` package. No CLI yet — this just
defines the wire shape and parsers the rest of the flow will plug
into.
* internal/updater/manifest.go — Manifest / Release types,
ManifestSchemaVersion = 1, the hardcoded URL
https://releases.thaloco.com/banger/manifest.json (var instead
of const so tests can point at httptest), and FetchManifest /
ParseManifest / Manifest.LookupRelease / Manifest.Latest.
The manifest only references URLs (tarball, SHA256SUMS, optional
signature); actual binary hashes come from SHA256SUMS itself,
so manifest tampering can't substitute a hash for a known-good
tarball.
SchemaVersion gates forward-compat: a CLI that doesn't know its
server's schema_version refuses to update rather than guessing.
* internal/updater/sha256sums.go — ParseSHA256Sums tolerates both
GNU `<digest> <file>` (with optional `*` binary prefix) and
BSD `SHA256 (file) = <digest>` formats. Comments and blank
lines are skipped; malformed lines that LOOK like entries are
rejected (silent skipping is the wrong failure mode for a
security-relevant input). Digests are lowercased so the caller
can `==`-compare without worrying about case.
Caps: 1 MiB on the manifest body, 16 KiB on SHA256SUMS, 256 MiB on
release tarballs. Generous-but-bounded; bumping requires a code
change so a server-side mistake can't fill the disk.
Tests: ParseManifest happy path, schema-version-too-new rejection,
five malformed-input cases. ParseSHA256Sums covers GNU + BSD +
star-prefix + comments-and-blanks, six malformed-input rejections,
case-insensitive digest normalisation. FetchManifest end-to-end via
httptest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
113 lines
3.5 KiB
Go
113 lines
3.5 KiB
Go
package updater
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const sampleManifest = `{
|
|
"schema_version": 1,
|
|
"latest_stable": "v0.1.1",
|
|
"releases": [
|
|
{
|
|
"version": "v0.1.0",
|
|
"tarball_url": "https://releases.thaloco.com/banger/v0.1.0/banger-v0.1.0-linux-amd64.tar.gz",
|
|
"sha256sums_url": "https://releases.thaloco.com/banger/v0.1.0/SHA256SUMS",
|
|
"sha256sums_sig_url": "https://releases.thaloco.com/banger/v0.1.0/SHA256SUMS.sig",
|
|
"released_at": "2026-04-29T10:00:00Z"
|
|
},
|
|
{
|
|
"version": "v0.1.1",
|
|
"tarball_url": "https://releases.thaloco.com/banger/v0.1.1/banger-v0.1.1-linux-amd64.tar.gz",
|
|
"sha256sums_url": "https://releases.thaloco.com/banger/v0.1.1/SHA256SUMS",
|
|
"sha256sums_sig_url": "https://releases.thaloco.com/banger/v0.1.1/SHA256SUMS.sig",
|
|
"released_at": "2026-05-01T10:00:00Z"
|
|
}
|
|
]
|
|
}`
|
|
|
|
func TestParseManifestHappyPath(t *testing.T) {
|
|
m, err := ParseManifest([]byte(sampleManifest))
|
|
if err != nil {
|
|
t.Fatalf("ParseManifest: %v", err)
|
|
}
|
|
if m.LatestStable != "v0.1.1" || len(m.Releases) != 2 {
|
|
t.Fatalf("manifest = %+v, want 2 releases with latest_stable=v0.1.1", m)
|
|
}
|
|
}
|
|
|
|
func TestParseManifestRejectsNewerSchema(t *testing.T) {
|
|
body := strings.Replace(sampleManifest, `"schema_version": 1`, `"schema_version": 99`, 1)
|
|
_, err := ParseManifest([]byte(body))
|
|
if err == nil || !strings.Contains(err.Error(), "newer than this CLI") {
|
|
t.Fatalf("err = %v, want newer-schema rejection", err)
|
|
}
|
|
}
|
|
|
|
func TestParseManifestRejectsMissingFields(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{name: "missing_schema_version", body: `{"latest_stable":"v0.1.0","releases":[]}`},
|
|
{name: "missing_tarball_url", body: `{"schema_version":1,"latest_stable":"v0.1.0","releases":[{"version":"v0.1.0","sha256sums_url":"x"}]}`},
|
|
{name: "missing_sha256sums_url", body: `{"schema_version":1,"latest_stable":"v0.1.0","releases":[{"version":"v0.1.0","tarball_url":"x"}]}`},
|
|
{name: "empty_version", body: `{"schema_version":1,"latest_stable":"v0.1.0","releases":[{"tarball_url":"x","sha256sums_url":"y"}]}`},
|
|
{name: "garbage", body: "not json"},
|
|
} {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if _, err := ParseManifest([]byte(tc.body)); err == nil {
|
|
t.Fatalf("expected error parsing %s; got success", tc.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestManifestLookupRelease(t *testing.T) {
|
|
m, _ := ParseManifest([]byte(sampleManifest))
|
|
r, err := m.LookupRelease("v0.1.0")
|
|
if err != nil {
|
|
t.Fatalf("LookupRelease(v0.1.0): %v", err)
|
|
}
|
|
if !strings.HasSuffix(r.TarballURL, "banger-v0.1.0-linux-amd64.tar.gz") {
|
|
t.Fatalf("wrong tarball url: %s", r.TarballURL)
|
|
}
|
|
if _, err := m.LookupRelease("v9.9.9"); err == nil {
|
|
t.Fatal("expected error looking up missing release")
|
|
}
|
|
}
|
|
|
|
func TestManifestLatest(t *testing.T) {
|
|
m, _ := ParseManifest([]byte(sampleManifest))
|
|
r, err := m.Latest()
|
|
if err != nil {
|
|
t.Fatalf("Latest: %v", err)
|
|
}
|
|
if r.Version != "v0.1.1" {
|
|
t.Fatalf("Latest.Version = %s, want v0.1.1", r.Version)
|
|
}
|
|
}
|
|
|
|
func TestFetchManifestRoundTrip(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(sampleManifest))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
// Drive FetchManifest by overriding the global URL temporarily.
|
|
prev := manifestURL
|
|
manifestURL = srv.URL
|
|
defer func() { manifestURL = prev }()
|
|
|
|
m, err := FetchManifest(context.Background(), srv.Client())
|
|
if err != nil {
|
|
t.Fatalf("FetchManifest: %v", err)
|
|
}
|
|
if m.LatestStable != "v0.1.1" {
|
|
t.Fatalf("LatestStable = %s", m.LatestStable)
|
|
}
|
|
}
|