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>
This commit is contained in:
parent
abd5d6f5ab
commit
fb6d2b1dae
4 changed files with 471 additions and 0 deletions
169
internal/updater/manifest.go
Normal file
169
internal/updater/manifest.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
113
internal/updater/manifest_test.go
Normal file
113
internal/updater/manifest_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
91
internal/updater/sha256sums.go
Normal file
91
internal/updater/sha256sums.go
Normal file
|
|
@ -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><whitespace><filename>
|
||||||
|
//
|
||||||
|
// 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 `<digest> <filename>`, got %q", lineNo, line)
|
||||||
|
}
|
||||||
|
digest := fields[0]
|
||||||
|
// GNU format may prefix the filename with `*` for binary mode
|
||||||
|
// (`<digest> *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
|
||||||
|
}
|
||||||
98
internal/updater/sha256sums_test.go
Normal file
98
internal/updater/sha256sums_test.go
Normal file
|
|
@ -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 `<digest> *<filename>`.
|
||||||
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue