banger/internal/updater/manifest.go
Thales Maciel fb6d2b1dae
updater: manifest + SHA256SUMS parsing scaffolding
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>
2026-04-29 12:24:36 -03:00

169 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package updater drives `banger update`: discover a new release,
// download + verify it, swap binaries atomically, restart the systemd
// units, run doctor, roll back on failure. The package is split across
// files by responsibility — manifest.go owns the release-discovery
// shape, the rest is in their own files.
package updater
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// manifestURL is the canonical URL of banger's release manifest on
// the Cloudflare R2 bucket. Hardcoded (rather than pulling from
// config) so a compromised daemon config can't redirect the updater
// to a different bucket. Var (not const) only because tests need to
// point at an httptest.Server; production never mutates it.
//
// The bucket lives at releases.thaloco.com; the path /banger/ scopes
// it inside the bucket so the same host can serve other projects'
// release artifacts later.
var manifestURL = "https://releases.thaloco.com/banger/manifest.json"
// ManifestURL exposes the configured URL for callers that want to
// surface it in user-facing output (e.g. `banger update --check`).
func ManifestURL() string { return manifestURL }
// MaxManifestBytes caps the manifest download size. The manifest is
// JSON with a small bounded shape (10s of releases × ~200 bytes
// each); 1 MiB is generous and protects us from a server that
// accidentally serves an arbitrary file.
const MaxManifestBytes int64 = 1 << 20
// MaxSHA256SumsBytes caps the SHA256SUMS download. One line per
// release artifact (today: one line for the tarball); 16 KiB is
// orders of magnitude over what we'd ever publish.
const MaxSHA256SumsBytes int64 = 16 * 1024
// MaxTarballBytes caps the release-tarball download. Banger's three
// binaries plus a SHA256SUMS file fit comfortably under this; if a
// future release approaches the cap, bump intentionally and ship a
// note in CHANGELOG.
const MaxTarballBytes int64 = 256 * 1024 * 1024
// Manifest is the top-level shape of releases.thaloco.com/banger/manifest.json.
// SchemaVersion lets us evolve the structure without breaking older
// CLIs — a CLI that doesn't recognise its current SchemaVersion
// refuses to update rather than guessing.
type Manifest struct {
SchemaVersion int `json:"schema_version"`
LatestStable string `json:"latest_stable"`
Releases []Release `json:"releases"`
}
// Release describes one published banger build. The tarball + the
// SHA256SUMS file (and optionally its cosign signature) live at the
// URLs listed here; the actual binary hashes come from SHA256SUMS,
// not from the manifest, so manifest tampering can't substitute a
// hash for a known-good tarball.
type Release struct {
Version string `json:"version"`
TarballURL string `json:"tarball_url"`
SHA256SumsURL string `json:"sha256sums_url"`
SHA256SumsSigURL string `json:"sha256sums_sig_url,omitempty"`
ReleasedAt time.Time `json:"released_at"`
}
// ManifestSchemaVersion is the SchemaVersion this CLI knows how to
// parse. Bumped together with any breaking change in Manifest /
// Release.
const ManifestSchemaVersion = 1
// FetchManifest downloads the release manifest and validates its
// shape. Returns an error if the server is unreachable, returns
// non-2xx, exceeds the size cap, or the schema_version is newer
// than this CLI knows.
func FetchManifest(ctx context.Context, client *http.Client) (Manifest, error) {
if client == nil {
client = http.DefaultClient
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
if err != nil {
return Manifest{}, err
}
resp, err := client.Do(req)
if err != nil {
return Manifest{}, fmt.Errorf("fetch manifest: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return Manifest{}, fmt.Errorf("fetch manifest: HTTP %s", resp.Status)
}
if resp.ContentLength > MaxManifestBytes {
return Manifest{}, fmt.Errorf("manifest is %d bytes, exceeds %d-byte cap", resp.ContentLength, MaxManifestBytes)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, MaxManifestBytes+1))
if err != nil {
return Manifest{}, fmt.Errorf("read manifest: %w", err)
}
if int64(len(body)) > MaxManifestBytes {
return Manifest{}, fmt.Errorf("manifest body exceeded %d-byte cap", MaxManifestBytes)
}
return ParseManifest(body)
}
// ParseManifest unmarshals manifest bytes and validates the schema
// version. Exposed as a separate function so tests can drive it
// without an HTTP server.
func ParseManifest(body []byte) (Manifest, error) {
var m Manifest
if err := json.Unmarshal(body, &m); err != nil {
return Manifest{}, fmt.Errorf("parse manifest: %w", err)
}
if m.SchemaVersion == 0 {
return Manifest{}, fmt.Errorf("manifest missing schema_version")
}
if m.SchemaVersion > ManifestSchemaVersion {
return Manifest{}, fmt.Errorf("manifest schema_version %d is newer than this CLI knows (%d); upgrade banger to read it", m.SchemaVersion, ManifestSchemaVersion)
}
if strings.TrimSpace(m.LatestStable) == "" && len(m.Releases) > 0 {
return Manifest{}, fmt.Errorf("manifest missing latest_stable")
}
for i, r := range m.Releases {
if strings.TrimSpace(r.Version) == "" {
return Manifest{}, fmt.Errorf("release[%d]: empty version", i)
}
if strings.TrimSpace(r.TarballURL) == "" {
return Manifest{}, fmt.Errorf("release[%d] (%s): empty tarball_url", i, r.Version)
}
if strings.TrimSpace(r.SHA256SumsURL) == "" {
return Manifest{}, fmt.Errorf("release[%d] (%s): empty sha256sums_url", i, r.Version)
}
}
return m, nil
}
// LookupRelease finds the release with the given version (e.g.
// "v0.1.0") in the manifest. Returns an error when no match exists —
// helpful when a user passes `--to v9.9.9` against a manifest that
// hasn't seen v9.9.9 yet.
func (m Manifest) LookupRelease(version string) (Release, error) {
wanted := strings.TrimSpace(version)
if wanted == "" {
return Release{}, fmt.Errorf("version is required")
}
for _, r := range m.Releases {
if r.Version == wanted {
return r, nil
}
}
available := make([]string, 0, len(m.Releases))
for _, r := range m.Releases {
available = append(available, r.Version)
}
return Release{}, fmt.Errorf("release %q not found in manifest (available: %s)", wanted, strings.Join(available, ", "))
}
// Latest returns the release matching the manifest's latest_stable
// pointer. Errors when the pointer doesn't reference any listed
// release — that's a manifest publishing bug worth surfacing rather
// than silently picking some other release.
func (m Manifest) Latest() (Release, error) {
return m.LookupRelease(m.LatestStable)
}