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>
91 lines
2.8 KiB
Go
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
|
|
}
|