ajhahn.de
← eeco
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"
}