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>
117 lines
4.3 KiB
Go
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")
|
|
}
|