banger/internal/updater/sha256sums.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

91 lines
2.8 KiB
Go

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
}