banger/internal/updater/download.go
Thales Maciel 91af367208
updater: download/stage/swap/rollback flow steps
The pure-logic core of `banger update`. No CLI yet; this commit
ships the steps the next commit's command will orchestrate.

  * download.go — DownloadRelease fetches SHA256SUMS, parses it,
    looks up the tarball's basename, then streams the tarball
    through download.FetchVerified so the hash is checked on the
    fly. Returns the SHA256SUMS bytes alongside so a future
    cosign-verification step can validate them against an embedded
    public key before trusting the hashes inside.
    Also: fetchBounded for small bounded GETs (manifest, sums file,
    future signature), DefaultStagingDir, EnsureStagingDir,
    PrepareCleanStaging.
  * stage.go — StageTarball reads gzip+tar, validates the entry
    set is exactly {banger, bangerd, banger-vsock-agent} (no
    extras, no missing, no path traversal, no non-regular files),
    extracts at mode 0755 regardless of what the tarball claims.
    StagedRelease records the resulting paths.
  * swap.go — InstallTargets pins the canonical install paths
    (/usr/local/bin/banger, /usr/local/bin/bangerd,
    /usr/local/lib/banger/banger-vsock-agent). Swap orders the
    three replacements vsock → bangerd → banger so the most
    impactful binary (the CLI) goes last; each step uses
    system.AtomicReplace and accumulates a SwapResult so partial
    failures can be rolled back cleanly. Rollback unwinds in
    reverse, joining errors so a half-rolled-back state surfaces
    enough info for an operator to fix manually. CleanupBackups
    removes the .previous trail after `banger doctor` confirms
    the new install is healthy.
  * installmeta.UpdateBuildInfo — small helper that refreshes
    Version/Commit/BuiltAt on /etc/banger/install.toml without
    re-running the full system install. Preserves OwnerUser/UID/
    GID/Home and the original InstalledAt timestamp.

Tests: stage rejects extra entries / missing entries / path
traversal / non-regular files; happy-path stages all three at 0755
with correct contents. Swap+Rollback covers the all-three-succeed
path (then verifies .previous backups exist + rollback restores
old contents) AND the partial-failure path (third swap blocked by
a non-dir parent → SwappedTargets = 2 → rollback unwinds those
two cleanly). DownloadRelease covers happy path, tarball-not-in-
SHA256SUMS, and propagated sha256 mismatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:30:22 -03:00

117 lines
4.3 KiB
Go

package updater
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"banger/internal/download"
)
// DownloadRelease fetches the SHA256SUMS file for `release`, looks up
// the tarball's basename in it, then fetches the tarball with on-the-
// fly hash verification. The tarball lands at dstPath; the function
// errors on any verification failure and removes the partial file
// before returning.
//
// SHA256SUMS bytes are returned alongside so the caller can
// cosign-verify them against an embedded public key before trusting
// the hashes inside. Without that step this function is only as
// secure as TLS; see verify_signature.go for the cosign tie-in.
func DownloadRelease(ctx context.Context, client *http.Client, release Release, dstPath string) (sumsBody []byte, err error) {
if client == nil {
client = http.DefaultClient
}
sumsBody, err = fetchBounded(ctx, client, release.SHA256SumsURL, MaxSHA256SumsBytes)
if err != nil {
return nil, fmt.Errorf("fetch SHA256SUMS: %w", err)
}
sums, err := ParseSHA256Sums(sumsBody)
if err != nil {
return nil, fmt.Errorf("parse SHA256SUMS: %w", err)
}
tarballName := path.Base(release.TarballURL)
expected, ok := sums[tarballName]
if !ok {
return nil, fmt.Errorf("SHA256SUMS does not list %q", tarballName)
}
if _, err := download.FetchVerified(ctx, client, release.TarballURL, expected, MaxTarballBytes, dstPath); err != nil {
return nil, fmt.Errorf("fetch tarball: %w", err)
}
return sumsBody, nil
}
// fetchBounded does a small bounded GET — used for the manifest, the
// SHA256SUMS file, and (later) the cosign signature. Anything bigger
// goes through download.FetchVerified, which adds the on-the-fly
// hash check.
func fetchBounded(ctx context.Context, client *http.Client, url string, maxBytes int64) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("fetch %s: HTTP %s", url, resp.Status)
}
if resp.ContentLength > maxBytes {
return nil, fmt.Errorf("fetch %s: %d bytes exceeds %d-byte cap", url, resp.ContentLength, maxBytes)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil {
return nil, fmt.Errorf("read %s: %w", url, err)
}
if int64(len(body)) > maxBytes {
return nil, fmt.Errorf("%s exceeded %d-byte cap mid-stream", url, maxBytes)
}
return body, nil
}
// EnsureStagingDir creates the staging directory with restrictive
// permissions (0700, owned by the caller — typically root in system
// mode). Any pre-existing contents are NOT cleared; that's
// PrepareCleanStaging's job.
func EnsureStagingDir(stagingDir string) error {
return os.MkdirAll(stagingDir, 0o700)
}
// PrepareCleanStaging wipes anything left in the staging dir from a
// prior aborted update, then re-creates the directory. Distinct from
// EnsureStagingDir because we don't want to nuke the dir unless
// we're ABOUT to use it — having a leftover staged tree from a
// prior failed run is sometimes useful for diagnostics.
func PrepareCleanStaging(stagingDir string) error {
if err := os.RemoveAll(stagingDir); err != nil {
return fmt.Errorf("clear staging %s: %w", stagingDir, err)
}
return EnsureStagingDir(stagingDir)
}
// DefaultStagingDir is where the updater stages downloads +
// extracted binaries when no explicit dir is configured. Sits under
// banger's system CacheDir (typically /var/cache/banger/updates) so:
// - the systemd unit's CacheDirectory=banger keeps the path
// writable for the helper.
// - `banger system uninstall --purge` cleans it.
// - it sits beside the OCI and kernel caches without colliding.
//
// Atomicity caveat: we expect /var/cache and /usr/local to share a
// filesystem (default on essentially every Linux install). On a host
// with /usr split onto a separate volume, the swap step's os.Rename
// would fall through to a copy + delete and lose its atomicity
// guarantee. We document this rather than detect-and-error for
// v0.1.0; the worst-case symptom is a brief window where a binary is
// half-written, which `banger doctor` would catch in step 7.
func DefaultStagingDir(cacheDir string) string {
return filepath.Join(cacheDir, "updates")
}