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