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