From fb6d2b1dae2bf748fd25a4572e508573e12265fc Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Wed, 29 Apr 2026 12:24:36 -0300 Subject: [PATCH] updater: manifest + SHA256SUMS parsing scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` ` (with optional `*` binary prefix) and BSD `SHA256 (file) = ` 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) --- internal/updater/manifest.go | 169 ++++++++++++++++++++++++++++ internal/updater/manifest_test.go | 113 +++++++++++++++++++ internal/updater/sha256sums.go | 91 +++++++++++++++ internal/updater/sha256sums_test.go | 98 ++++++++++++++++ 4 files changed, 471 insertions(+) create mode 100644 internal/updater/manifest.go create mode 100644 internal/updater/manifest_test.go create mode 100644 internal/updater/sha256sums.go create mode 100644 internal/updater/sha256sums_test.go diff --git a/internal/updater/manifest.go b/internal/updater/manifest.go new file mode 100644 index 0000000..b949bdd --- /dev/null +++ b/internal/updater/manifest.go @@ -0,0 +1,169 @@ +// 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) +} diff --git a/internal/updater/manifest_test.go b/internal/updater/manifest_test.go new file mode 100644 index 0000000..abb4efc --- /dev/null +++ b/internal/updater/manifest_test.go @@ -0,0 +1,113 @@ +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) + } +} diff --git a/internal/updater/sha256sums.go b/internal/updater/sha256sums.go new file mode 100644 index 0000000..0a12fe6 --- /dev/null +++ b/internal/updater/sha256sums.go @@ -0,0 +1,91 @@ +package updater + +import ( + "bufio" + "fmt" + "strings" +) + +// ParseSHA256Sums turns the body of a sha256sum-format file into a +// filename → hex-digest map. Format per line: +// +// <64 hex chars> +// +// Anything else (blank lines, comments starting with '#') is +// tolerated. Returns an error only when a line that LOOKS like an +// entry is malformed — silent skipping of garbage would be the wrong +// failure mode for a security-relevant input. +// +// Used by `banger update` after downloading the SHA256SUMS file +// alongside the release tarball: look up the tarball's basename in +// the resulting map to get its expected hash. +func ParseSHA256Sums(body []byte) (map[string]string, error) { + out := map[string]string{} + scanner := bufio.NewScanner(strings.NewReader(string(body))) + scanner.Buffer(make([]byte, 64*1024), 64*1024) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + // Tolerate the BSD-style `SHA256 (file) = hex` form too — + // some signing pipelines emit it. The GNU-style is what + // `sha256sum` defaults to. + if rest, ok := strings.CutPrefix(line, "SHA256 ("); ok { + closingParen := strings.Index(rest, ")") + eq := strings.LastIndex(rest, "= ") + if closingParen <= 0 || eq <= closingParen { + return nil, fmt.Errorf("line %d: malformed BSD-style sum line", lineNo) + } + file := strings.TrimSpace(rest[:closingParen]) + digest := strings.TrimSpace(rest[eq+2:]) + if !looksLikeSHA256(digest) { + return nil, fmt.Errorf("line %d: digest %q is not a 64-char hex sha256", lineNo, digest) + } + out[file] = strings.ToLower(digest) + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + return nil, fmt.Errorf("line %d: expected ` `, got %q", lineNo, line) + } + digest := fields[0] + // GNU format may prefix the filename with `*` for binary mode + // (` *file`) or a leading space; trim it. + filename := strings.TrimSpace(strings.Join(fields[1:], " ")) + filename = strings.TrimPrefix(filename, "*") + if !looksLikeSHA256(digest) { + return nil, fmt.Errorf("line %d: digest %q is not a 64-char hex sha256", lineNo, digest) + } + out[filename] = strings.ToLower(digest) + } + if err := scanner.Err(); err != nil { + return nil, err + } + if len(out) == 0 { + return nil, fmt.Errorf("SHA256SUMS body contained no entries") + } + return out, nil +} + +// looksLikeSHA256 returns true when s is exactly 64 hex characters. +// Doesn't check that those bytes are themselves a valid digest of +// anything — that's the cryptographic verifier's job, not the +// parser's. +func looksLikeSHA256(s string) bool { + if len(s) != 64 { + return false + } + for _, c := range s { + switch { + case c >= '0' && c <= '9': + case c >= 'a' && c <= 'f': + case c >= 'A' && c <= 'F': + default: + return false + } + } + return true +} diff --git a/internal/updater/sha256sums_test.go b/internal/updater/sha256sums_test.go new file mode 100644 index 0000000..77b3094 --- /dev/null +++ b/internal/updater/sha256sums_test.go @@ -0,0 +1,98 @@ +package updater + +import ( + "strings" + "testing" +) + +func TestParseSHA256SumsGNUFormat(t *testing.T) { + body := []byte(`# header comment +0000000000000000000000000000000000000000000000000000000000000001 banger-v0.1.0-linux-amd64.tar.gz +0000000000000000000000000000000000000000000000000000000000000002 banger-v0.1.0-linux-amd64.tar.gz.sig +`) + got, err := ParseSHA256Sums(body) + if err != nil { + t.Fatalf("ParseSHA256Sums: %v", err) + } + if got["banger-v0.1.0-linux-amd64.tar.gz"] != "0000000000000000000000000000000000000000000000000000000000000001" { + t.Fatalf("tarball digest = %q", got["banger-v0.1.0-linux-amd64.tar.gz"]) + } + if len(got) != 2 { + t.Fatalf("got %d entries, want 2", len(got)) + } +} + +func TestParseSHA256SumsBSDFormat(t *testing.T) { + body := []byte(`SHA256 (banger-v0.1.0-linux-amd64.tar.gz) = 0000000000000000000000000000000000000000000000000000000000000001 +`) + got, err := ParseSHA256Sums(body) + if err != nil { + t.Fatalf("ParseSHA256Sums: %v", err) + } + if got["banger-v0.1.0-linux-amd64.tar.gz"] != "0000000000000000000000000000000000000000000000000000000000000001" { + t.Fatalf("digest = %q", got["banger-v0.1.0-linux-amd64.tar.gz"]) + } +} + +func TestParseSHA256SumsBinaryStarPrefix(t *testing.T) { + // `sha256sum -b` emits ` *`. + body := []byte(`0000000000000000000000000000000000000000000000000000000000000001 *banger-v0.1.0-linux-amd64.tar.gz +`) + got, err := ParseSHA256Sums(body) + if err != nil { + t.Fatalf("ParseSHA256Sums: %v", err) + } + if _, ok := got["banger-v0.1.0-linux-amd64.tar.gz"]; !ok { + t.Fatalf("entries = %v, want star-prefix stripped", got) + } +} + +func TestParseSHA256SumsTolerantOfBlankAndComments(t *testing.T) { + body := []byte(` +# top comment +0000000000000000000000000000000000000000000000000000000000000001 a + +# inline comment +0000000000000000000000000000000000000000000000000000000000000002 b +`) + got, err := ParseSHA256Sums(body) + if err != nil { + t.Fatalf("ParseSHA256Sums: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d, want 2", len(got)) + } +} + +func TestParseSHA256SumsRejectsMalformed(t *testing.T) { + for _, tc := range []struct { + name string + body string + }{ + {name: "short_digest", body: "abc file"}, + {name: "non_hex_digest", body: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz file"}, + {name: "no_filename", body: "0000000000000000000000000000000000000000000000000000000000000001"}, + {name: "empty_body", body: ""}, + {name: "only_comments", body: "# comment\n# more\n"}, + {name: "bsd_no_eq", body: "SHA256 (file) 0000000000000000000000000000000000000000000000000000000000000001"}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + if _, err := ParseSHA256Sums([]byte(tc.body)); err == nil { + t.Fatalf("expected error for %s", tc.name) + } + }) + } +} + +func TestParseSHA256SumsLowercasesDigest(t *testing.T) { + body := []byte(`ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890 upper +`) + got, err := ParseSHA256Sums(body) + if err != nil { + t.Fatalf("ParseSHA256Sums: %v", err) + } + if got["upper"] != strings.ToLower("ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890") { + t.Fatalf("digest not lowercased: %q", got["upper"]) + } +}