banger/internal/updater/stage.go
Thales Maciel fae28e3d8b
update: docs + publish script for the self-update feature
README gets a top-level Updating section; docs/privileges.md gains
a step-by-step trust-model writeup of `banger update`. The new
scripts/publish-banger-release.sh drives the manual release cut:
build, tar, sha256sum, cosign sign-blob, verify against the embedded
public key, jq-merge into manifest.json, rclone upload to the R2
bucket. Refuses outright if the embedded key is still the placeholder
so we can't accidentally publish an unverifiable release. Also folds
in gofmt drift accumulated across the updater package and a few
sibling files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:43:46 -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
}