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>
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
|
|
}
|