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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue