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>
107 lines
3 KiB
Go
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
|
|
}
|