Go 237 lines
// Package selfupdate implements eeco's opt-in self-replace path for
// `eeco update --apply`. It downloads the platform release archive,
// verifies the SHA256SUMS keyless cosign signature, verifies the
// GitHub build-provenance attestation, then atomically replaces the
// running binary with the verified one. The binary swap is the one
// allowed write outside the workspace (Constraint 1); every other
// write — the download staging area, the backup copy, the ledger
// entry — lands inside <workspace>/state/.
//
// The bare `eeco update` (no flag) keeps its read-only behaviour;
// this package is invoked only when --apply is set.
package selfupdate
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/ajhahnde/eeco/internal/config"
)
// DefaultBaseURL is the production GitHub Releases base URL.
const DefaultBaseURL = "https://github.com/ajhahnde/eeco/releases/download"
// CosignIdentityRegexp matches the same identity baked into the
// release-notes block of .github/workflows/release.yml. The contract
// is: keyless signatures only come from a tag-push run of the release
// workflow in this repo.
const CosignIdentityRegexp = `^https://github.com/ajhahnde/eeco/\.github/workflows/release\.yml@refs/tags/v`
// CosignOIDCIssuer is the OIDC issuer the keyless cosign signature
// trusts. Matches the release-notes verification line.
const CosignOIDCIssuer = "https://token.actions.githubusercontent.com"
// ProvenanceRepo is the repository slug used by `gh attestation verify`.
const ProvenanceRepo = "ajhahnde/eeco"
// Options injects collaborators for testing. All fields are optional;
// zero values are replaced with production defaults at run time.
type Options struct {
BaseURL string
HTTPClient *http.Client
Executable func() (string, error)
RunCmd func(name string, args ...string) (combined string, err error)
Now func() time.Time
GOOS string
GOARCH string
}
// Apply performs the verified self-replace. cfg supplies the workspace
// (where staging, backup, and ledger live). currentVersion is the
// running binary's version string (e.g. "v1.4.1"). latestTag is the
// release tag to apply (e.g. "v1.5.0"). Exit code conventions match
// the rest of the CLI: 0 success, 1 finding/failure, 2 blocked.
func Apply(cfg *config.Config, currentVersion, latestTag string, stdout, stderr io.Writer, opt Options) int {
o := withDefaults(opt)
running, err := o.Executable()
if err != nil {
fmt.Fprintln(stderr, "eeco update --apply: cannot resolve running binary:", err)
return 1
}
if kind, hint := detectPackageManager(running); kind != "" {
fmt.Fprintf(stdout, "eeco update --apply: this build appears to be installed via %s.\n", kind)
fmt.Fprintf(stdout, " %s\n", hint)
return 2
}
stagingDir := filepath.Join(cfg.Workspace, "state", "update-"+latestTag)
if err := os.MkdirAll(stagingDir, 0o755); err != nil {
fmt.Fprintln(stderr, "eeco update --apply: prepare staging dir:", err)
return 1
}
archiveName := archiveBasename(latestTag, o.GOOS, o.GOARCH)
files := []struct {
url string
dst string
}{
{o.BaseURL + "/" + latestTag + "/" + archiveName, filepath.Join(stagingDir, archiveName)},
{o.BaseURL + "/" + latestTag + "/SHA256SUMS", filepath.Join(stagingDir, "SHA256SUMS")},
{o.BaseURL + "/" + latestTag + "/SHA256SUMS.sig", filepath.Join(stagingDir, "SHA256SUMS.sig")},
{o.BaseURL + "/" + latestTag + "/SHA256SUMS.pem", filepath.Join(stagingDir, "SHA256SUMS.pem")},
}
fmt.Fprintln(stdout, "eeco update --apply: downloading", latestTag)
for _, f := range files {
if err := download(o.HTTPClient, f.url, f.dst); err != nil {
fmt.Fprintf(stderr, " download %s: %v\n", filepath.Base(f.dst), err)
return 1
}
}
archivePath := files[0].dst
sumsPath := files[1].dst
sigPath := files[2].dst
certPath := files[3].dst
fmt.Fprintln(stdout, " verifying SHA256SUMS signature (cosign)")
if err := verifyCosign(o.RunCmd, sumsPath, sigPath, certPath); err != nil {
if errors.Is(err, exec.ErrNotFound) {
fmt.Fprintln(stderr, " cosign is not on PATH (required for --apply).")
return 2
}
fmt.Fprintln(stderr, " cosign verify-blob failed:", err)
return 1
}
fmt.Fprintln(stdout, " verifying archive sha256 against SHA256SUMS")
wantHash, err := checksumFor(sumsPath, archiveName)
if err != nil {
fmt.Fprintln(stderr, " read SHA256SUMS:", err)
return 1
}
gotHash, err := sha256File(archivePath)
if err != nil {
fmt.Fprintln(stderr, " hash archive:", err)
return 1
}
if gotHash != wantHash {
fmt.Fprintf(stderr, " archive sha256 mismatch: want %s, got %s\n", wantHash, gotHash)
return 1
}
fmt.Fprintln(stdout, " verifying build-provenance attestation (gh)")
if err := verifyAttestation(o.RunCmd, archivePath); err != nil {
if errors.Is(err, exec.ErrNotFound) {
fmt.Fprintln(stderr, " gh is not on PATH (required for --apply).")
return 2
}
fmt.Fprintln(stderr, " gh attestation verify failed:", err)
return 1
}
stagedDir := filepath.Join(stagingDir, "staged")
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
fmt.Fprintln(stderr, " prepare staged dir:", err)
return 1
}
newBin, err := extract(archivePath, stagedDir, o.GOOS)
if err != nil {
fmt.Fprintln(stderr, " extract:", err)
return 1
}
backup := filepath.Join(stagingDir, backupName(o.GOOS))
if err := copyFile(running, backup); err != nil {
fmt.Fprintln(stderr, " backup running binary:", err)
return 1
}
if err := swap(newBin, running); err != nil {
fmt.Fprintln(stderr, " swap binary:", err)
return 1
}
if err := writeLedger(cfg, Ledger{
Installed: true,
FromVersion: currentVersion,
ToVersion: latestTag,
RunningPath: running,
Backup: backup,
SHA256: gotHash,
At: o.Now().UTC().Format(time.RFC3339),
}); err != nil {
fmt.Fprintln(stderr, " write ledger:", err)
return 1
}
fmt.Fprintf(stdout, "eeco upgraded: %s -> %s (backup: %s)\n", currentVersion, latestTag, backup)
return 0
}
func withDefaults(o Options) Options {
if o.BaseURL == "" {
o.BaseURL = DefaultBaseURL
}
if o.HTTPClient == nil {
o.HTTPClient = &http.Client{Timeout: 5 * time.Minute}
}
if o.Executable == nil {
o.Executable = ResolveRunning
}
if o.RunCmd == nil {
o.RunCmd = defaultRunCmd
}
if o.Now == nil {
o.Now = time.Now
}
if o.GOOS == "" {
o.GOOS = runtime.GOOS
}
if o.GOARCH == "" {
o.GOARCH = runtime.GOARCH
}
return o
}
// ResolveRunning returns the absolute path of the running binary, with
// symlinks resolved. Mirrors the helper in internal/hooks/hooks.go so
// the swap operates on the same path the operator's `eeco` resolves to.
func ResolveRunning() (string, error) {
p, err := os.Executable()
if err != nil {
return "", err
}
if r, rerr := filepath.EvalSymlinks(p); rerr == nil {
p = r
}
return p, nil
}
func defaultRunCmd(name string, args ...string) (string, error) {
out, err := exec.Command(name, args...).CombinedOutput()
return string(out), err
}
func archiveBasename(tag, goos, goarch string) string {
ext := "tar.gz"
if goos == "windows" {
ext = "zip"
}
return fmt.Sprintf("eeco_%s_%s_%s.%s", tag, goos, goarch, ext)
}
func backupName(goos string) string {
if goos == "windows" {
return "eeco.exe.bak"
}
return "eeco.bak"
}