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

107 lines
3 KiB
Go

package updater
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// expectedReleaseEntries is the canonical set of files a release
// tarball must contain. Anything missing OR anything extra is
// rejected — banger update should not unpack arbitrary files into
// the staging dir.
var expectedReleaseEntries = []string{
"banger",
"bangerd",
"banger-vsock-agent",
}
// StagedRelease describes the result of unpacking a release tarball
// into a staging directory.
type StagedRelease struct {
BangerPath string
BangerdPath string
VsockAgentPath string
}
// StageTarball reads the gzipped tar at tarballPath and extracts the
// expected three banger binaries into stagingDir. Any extra entries,
// any path-traversal members, any non-regular-file members, and any
// missing required entry are rejected.
//
// The extracted binaries are mode 0o755 regardless of what the
// tarball claims — banger update is a privileged operation; we
// don't honour weird modes from the wire.
func StageTarball(tarballPath, stagingDir string) (StagedRelease, error) {
if err := os.MkdirAll(stagingDir, 0o700); err != nil {
return StagedRelease{}, err
}
f, err := os.Open(tarballPath)
if err != nil {
return StagedRelease{}, err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return StagedRelease{}, fmt.Errorf("open gzip: %w", err)
}
defer gz.Close()
expected := map[string]struct{}{}
for _, name := range expectedReleaseEntries {
expected[name] = struct{}{}
}
seen := map[string]string{}
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return StagedRelease{}, fmt.Errorf("read tar: %w", err)
}
rel := filepath.Clean(hdr.Name)
if rel == "." || rel == string(filepath.Separator) {
continue
}
if filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return StagedRelease{}, fmt.Errorf("unsafe path in tarball: %q", hdr.Name)
}
if _, ok := expected[rel]; !ok {
return StagedRelease{}, fmt.Errorf("unexpected entry in release tarball: %q (allowed: %v)", hdr.Name, expectedReleaseEntries)
}
if hdr.Typeflag != tar.TypeReg {
return StagedRelease{}, fmt.Errorf("entry %q is not a regular file (typeflag %d)", hdr.Name, hdr.Typeflag)
}
dst := filepath.Join(stagingDir, rel)
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return StagedRelease{}, err
}
if _, err := io.Copy(out, tr); err != nil {
_ = out.Close()
return StagedRelease{}, err
}
if err := out.Close(); err != nil {
return StagedRelease{}, err
}
seen[rel] = dst
}
for _, want := range expectedReleaseEntries {
if _, ok := seen[want]; !ok {
return StagedRelease{}, fmt.Errorf("release tarball is missing required entry %q", want)
}
}
return StagedRelease{
BangerPath: seen["banger"],
BangerdPath: seen["bangerd"],
VsockAgentPath: seen["banger-vsock-agent"],
}, nil
}