carlos / platform Public

cli: notice a newer release and update yourself (#161)

Once-a-day muted hint from apt.carlosframework.com/latest.txt after
successful member verbs (TTY, release builds, off-box, opt-out honored);
carlos update hands brew/apt installs to their manager and self-replaces
plain binaries only after the SHA256SUMS list verifies against the
embedded ed25519 update key (private half at SSM /carlos/cli-update-key).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 6083c9fe39bf9c7d5d92aae4ed1c96097a45b8c3 parent 99456c2
4 files changed, +592 −0
  • config.go +3 −0
  • main.go +8 −0
  • update.go +327 −0
  • update_test.go +254 −0
diff --git a/config.go b/config.go
index 2b5c60370..4ea31d193 100644
--- a/config.go
+++ b/config.go
@@ -42,6 +42,9 @@ const defaultConsole = "https://platform.carlosframework.com"
type cliConfig struct {
Console string `json:"console"`
Account string `json:"account"`
+ // UpdateCheck: "off" disables the once-a-day newer-release hint
+ // (update.go). Any other value — including absent — leaves it on.
+ UpdateCheck string `json:"update_check"`
}
// loadConfig reads one .carlos/config whole. A missing file is a zero
diff --git a/main.go b/main.go
index b7a85b745..1c0141a77 100644
--- a/main.go
+++ b/main.go
@@ -106,12 +106,18 @@ func main() {
host.RunAdopt(args)
case "vet":
vet.Run(args)
+ case "update":
+ runUpdate(args)
case "canary":
log.Fatalf("not implemented: %s", cmd)
default:
usage(os.Stderr)
os.Exit(2)
}
+ // After a verb that ran to success: the once-a-day newer-release hint
+ // (#161). Verbs that fail never reach here — log.Fatal exits — so a
+ // broken command is never followed by an upsell.
+ maybeUpdateHint(cmd)
}
// usage prints the top-level command list, grouped by the role you are in
@@ -151,6 +157,8 @@ Your apps — ship and run software on a CARLOS deployment
events (wakes, restarts, failures); -f follows live
ledger open ledgers: append | head | list | publish | blob | verify
vet check a release against the platform contract
+ update update this carlos binary to the latest release (or name
+ the brew/apt command when a package manager owns it)
Your deployment — stand up and administer boxes, routes and accounts
bootstrap prepare a host: units, dirs, user
diff --git a/update.go b/update.go
new file mode 100644
index 000000000..b8fdc13f7
--- /dev/null
+++ b/update.go
@@ -0,0 +1,327 @@
+// Self-update (#161): the binary knows its own version, the apt static
+// site publishes the latest, and carlosframework/releases holds the
+// artifacts — so a stale workstation install can notice and fix itself.
+// Everything here is workstation-only by construction: boxes (bucket env,
+// root) are rolled by update-platform-binary.sh and adoption, and a
+// self-updating box binary would fight that machinery.
+package main
+
+import (
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/version"
+)
+
+// Vars, not consts: the tests point them at httptest servers and a test
+// keypair. Production never changes them.
+var (
+ updateLatestURL = "https://apt.carlosframework.com/latest.txt"
+ updateDownloadBase = "https://github.com/carlosframework/releases/releases/latest/download"
+
+ // cliUpdatePubKey verifies SHA256SUMS.txt.sig on self-replace: the
+ // public half of the dedicated CLI-update keypair (private half at SSM
+ // /carlos/cli-update-key — NOT the release key; different blast
+ // radius, different rotation story).
+ cliUpdatePubKey = "R+EO3qQ47VsuKl9Qq/pkDXBucPYvzp5lU2ut4zpnJPc="
+)
+
+// releaseTag matches the version stamp of a published build ("v0.7.0").
+// Anything else — "dev" above all — never checks and never updates: there
+// is no meaningful "newer than dev".
+var releaseTag = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
+
+func releaseBuild(build string) bool { return releaseTag.MatchString(build) }
+
+// newerVersion reports whether latest (a release tag) is strictly newer
+// than current (also a release tag). Unparseable input is never "newer" —
+// a garbled feed must not nag anyone.
+func newerVersion(latest, current string) bool {
+ l, lok := release.ParseSemVer(strings.TrimPrefix(latest, "v"))
+ c, cok := release.ParseSemVer(strings.TrimPrefix(current, "v"))
+ return lok && cok && l.Compare(c) > 0
+}
+
+// noHintVerbs are the box/daemon/plumbing verbs the update hint stays out
+// of — either they run where updates are the platform's job, or their
+// output is parsed (version: the tap formula's test asserts on it).
+var noHintVerbs = map[string]bool{
+ "edge": true, "agent": true, "adopt": true, "bootstrap": true,
+ "add": true, "remove": true, "route": true, "ops": true,
+ "status": true, "canary": true, "vet": true, "release-keygen": true,
+ "version": true, "help": true, "-h": true, "--help": true,
+ "update": true,
+}
+
+type updateCache struct {
+ CheckedAt time.Time `json:"checked_at"`
+ Latest string `json:"latest"`
+}
+
+func updateCachePath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, ".carlos", "update-check.json"), nil
+}
+
+// fetchLatest asks the apt site which release is current. One small GET
+// with its own short deadline — the caller is done with its real work, and
+// a slow feed must cost nobody anything.
+func fetchLatest(timeout time.Duration) (string, error) {
+ client := &http.Client{Timeout: timeout}
+ res, err := client.Get(updateLatestURL)
+ if err != nil {
+ return "", err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("latest.txt: status %d", res.StatusCode)
+ }
+ raw, err := io.ReadAll(io.LimitReader(res.Body, 128))
+ if err != nil {
+ return "", err
+ }
+ tag := strings.TrimSpace(string(raw))
+ if !releaseBuild(tag) {
+ return "", fmt.Errorf("latest.txt: %q is not a release tag", tag)
+ }
+ return tag, nil
+}
+
+// maybeUpdateHint prints the one-line "a newer carlos exists" nudge after
+// a successful member verb — at most once per 24h (the network check and
+// the hint share the cache window), and only where a hint can possibly be
+// wanted: a human at a terminal, running a release build, off-box, not
+// opted out. Every failure is silence; this must never make a working
+// command look broken.
+func maybeUpdateHint(cmd string) {
+ if noHintVerbs[cmd] || !interactiveTerminal() || !releaseBuild(version.Build) {
+ return
+ }
+ if os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != "" || os.Getenv("CARLOS_DEPLOYMENT_DIR") != "" {
+ return
+ }
+ if os.Geteuid() == 0 || os.Getenv("CARLOS_NO_UPDATE_CHECK") != "" {
+ return
+ }
+ if off, err := configUpdateCheckOff(); err != nil || off {
+ return
+ }
+ path, err := updateCachePath()
+ if err != nil {
+ return
+ }
+ var cache updateCache
+ if raw, err := os.ReadFile(path); err == nil {
+ if json.Unmarshal(raw, &cache) == nil && time.Since(cache.CheckedAt) < 24*time.Hour {
+ return
+ }
+ }
+ latest, err := fetchLatest(1500 * time.Millisecond)
+ if err != nil {
+ return
+ }
+ // The cache is written even when up to date — "checked, nothing new"
+ // is exactly what should suppress tomorrow's re-check until due.
+ if raw, err := json.Marshal(updateCache{CheckedAt: time.Now().UTC(), Latest: latest}); err == nil {
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err == nil {
+ os.WriteFile(path, raw, 0o600)
+ }
+ }
+ if newerVersion(latest, version.Build) {
+ mutedf(stdout, "carlos %s is available (you have %s) — run: carlos update", latest, version.Build)
+ }
+}
+
+// configUpdateCheckOff walks the config files like the other defaults do:
+// nearest .carlos/config with an update_check key, else the global. Only
+// the literal "off" disables.
+func configUpdateCheckOff() (bool, error) {
+ if dir, werr := os.Getwd(); werr == nil {
+ for {
+ cfg, err := loadConfig(filepath.Join(dir, ".carlos", "config"))
+ if err != nil {
+ return false, err
+ }
+ if cfg.UpdateCheck != "" {
+ return cfg.UpdateCheck == "off", nil
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ break
+ }
+ dir = parent
+ }
+ }
+ path, perr := globalConfigPath()
+ if perr != nil {
+ return false, nil
+ }
+ cfg, err := loadConfig(path)
+ if err != nil {
+ return false, err
+ }
+ return cfg.UpdateCheck == "off", nil
+}
+
+// installMethod classifies how the running binary got where it is, so
+// `carlos update` hands package-managed installs to their manager instead
+// of fighting it.
+func installMethod(exe string) string {
+ if strings.Contains(exe, "/Cellar/") || strings.Contains(exe, "/homebrew/") || strings.Contains(exe, "/linuxbrew/") {
+ return "brew"
+ }
+ if dpkg, err := exec.LookPath("dpkg"); err == nil {
+ out, err := exec.Command(dpkg, "-S", exe).Output()
+ if err == nil && strings.HasPrefix(string(out), "carlos:") {
+ return "apt"
+ }
+ }
+ return "binary"
+}
+
+func runUpdate(args []string) {
+ fs := flag.NewFlagSet("update", flag.ExitOnError)
+ yes := fs.Bool("y", false, "replace without asking (plain-binary installs only)")
+ fs.Parse(args)
+ if os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != "" || os.Getenv("CARLOS_DEPLOYMENT_DIR") != "" {
+ log.Fatal("carlos update is for workstation installs — a box's binary is rolled by update-platform-binary.sh and adoption")
+ }
+ if os.Geteuid() == 0 {
+ log.Fatal("carlos update refuses to run as root — box binaries are rolled by the platform, not self-updated")
+ }
+ if !releaseBuild(version.Build) {
+ log.Fatalf("this is a %q build, not a published release — update by rebuilding from source", version.Build)
+ }
+ latest, err := fetchLatest(10 * time.Second)
+ if err != nil {
+ log.Fatalf("could not read the release feed: %v", err)
+ }
+ if !newerVersion(latest, version.Build) {
+ fmt.Fprintf(stdout, "carlos %s is the latest release — nothing to do\n", version.Build)
+ return
+ }
+ exe, err := os.Executable()
+ if err == nil {
+ exe, err = filepath.EvalSymlinks(exe)
+ }
+ if err != nil {
+ log.Fatalf("cannot locate the running binary: %v", err)
+ }
+ switch installMethod(exe) {
+ case "brew":
+ fmt.Fprintf(stdout, "carlos %s is available (you have %s)\n", latest, version.Build)
+ fmt.Fprintln(stdout, "this install is Homebrew's — update with: brew upgrade carlosframework/tap/carlos")
+ return
+ case "apt":
+ fmt.Fprintf(stdout, "carlos %s is available (you have %s)\n", latest, version.Build)
+ fmt.Fprintln(stdout, "this install is apt's — update with: sudo apt update && sudo apt install carlos")
+ return
+ }
+ fmt.Fprintf(stdout, "carlos %s is available (you have %s); this binary: %s\n", latest, version.Build, exe)
+ if !*yes {
+ if !interactiveTerminal() {
+ log.Fatal("not a terminal — pass -y to replace without asking")
+ }
+ if !promptYes(stdout, fmt.Sprintf("download %s and replace it? [y/N] ", latest)) {
+ return
+ }
+ }
+ if err := selfReplace(exe, latest); err != nil {
+ if errors.Is(err, os.ErrPermission) {
+ log.Fatalf("no permission to replace %s — re-run as: sudo carlos update", exe)
+ }
+ log.Fatalf("update: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s carlos %s -> %s (%s)\n", colorFor(stdout).ok("updated:"), version.Build, latest, exe)
+}
+
+// selfReplace downloads the matching artifact for this OS/arch, proves it
+// against the SIGNED checksum list, and renames it over exe — temp file
+// beside the target then rename, the same crash-safety rule as the config
+// writes, so no failure can leave half a binary answering `carlos`.
+func selfReplace(exe, latest string) error {
+ asset := fmt.Sprintf("carlos-%s-%s", runtime.GOOS, runtime.GOARCH)
+ sums, err := fetchUpdateFile("SHA256SUMS.txt", 1<<20)
+ if err != nil {
+ return err
+ }
+ sig, err := fetchUpdateFile("SHA256SUMS.txt.sig", 4096)
+ if err != nil {
+ return fmt.Errorf("%v (a release without a signed checksum list predates carlos update)", err)
+ }
+ pub, err := base64.StdEncoding.DecodeString(cliUpdatePubKey)
+ if err != nil || len(pub) != ed25519.PublicKeySize {
+ return errors.New("embedded update key is invalid — this binary cannot self-update")
+ }
+ rawSig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sig)))
+ if err != nil || !ed25519.Verify(ed25519.PublicKey(pub), sums, rawSig) {
+ return errors.New("SHA256SUMS.txt signature does not verify — refusing the download")
+ }
+ wantSum := ""
+ for _, line := range strings.Split(string(sums), "\n") {
+ fields := strings.Fields(line)
+ if len(fields) == 2 && fields[1] == asset {
+ wantSum = fields[0]
+ }
+ }
+ if wantSum == "" {
+ return fmt.Errorf("no %s in the release's checksum list", asset)
+ }
+ binData, err := fetchUpdateFile(asset, 256<<20)
+ if err != nil {
+ return err
+ }
+ if got := hex.EncodeToString(func() []byte { s := sha256.Sum256(binData); return s[:] }()); got != wantSum {
+ return fmt.Errorf("%s does not match its signed checksum — refusing to install it", asset)
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(exe), ".carlos-update-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp.Name())
+ if err := tmp.Chmod(0o755); err != nil {
+ tmp.Close()
+ return err
+ }
+ if _, err := tmp.Write(binData); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ return os.Rename(tmp.Name(), exe)
+}
+
+func fetchUpdateFile(name string, limit int64) ([]byte, error) {
+ client := &http.Client{Timeout: 5 * time.Minute}
+ res, err := client.Get(updateDownloadBase + "/" + name)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("%s: status %d", name, res.StatusCode)
+ }
+ return io.ReadAll(io.LimitReader(res.Body, limit))
+}
diff --git a/update_test.go b/update_test.go
new file mode 100644
index 000000000..47105e2c1
--- /dev/null
+++ b/update_test.go
@@ -0,0 +1,254 @@
+package main
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/version"
+)
+
+func TestReleaseBuild(t *testing.T) {
+ for tag, want := range map[string]bool{
+ "v0.7.0": true, "v10.2.33": true,
+ "dev": false, "0.7.0": false, "v0.7": false, "v0.7.0-1": false, "": false,
+ } {
+ if got := releaseBuild(tag); got != want {
+ t.Errorf("releaseBuild(%q) = %v, want %v", tag, got, want)
+ }
+ }
+}
+
+func TestNewerVersion(t *testing.T) {
+ for _, tc := range []struct {
+ latest, current string
+ want bool
+ }{
+ {"v0.8.0", "v0.7.0", true},
+ {"v1.0.0", "v0.9.9", true},
+ {"v0.7.0", "v0.7.0", false},
+ {"v0.6.0", "v0.7.0", false},
+ {"garbage", "v0.7.0", false}, // a garbled feed never nags
+ {"v0.8.0", "dev", false},
+ } {
+ if got := newerVersion(tc.latest, tc.current); got != tc.want {
+ t.Errorf("newerVersion(%q, %q) = %v, want %v", tc.latest, tc.current, got, tc.want)
+ }
+ }
+}
+
+// useVersion pins the build stamp for one test — it is a package var
+// precisely so release-only behavior is testable from a dev build.
+func useVersion(t *testing.T, v string) {
+ t.Helper()
+ old := version.Build
+ version.Build = v
+ t.Cleanup(func() { version.Build = old })
+}
+
+// latestServer serves latest.txt with the given tag and counts fetches.
+func latestServer(t *testing.T, tag string) *int {
+ t.Helper()
+ hits := 0
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits++
+ fmt.Fprintln(w, tag)
+ }))
+ t.Cleanup(ts.Close)
+ oldURL := updateLatestURL
+ updateLatestURL = ts.URL
+ t.Cleanup(func() { updateLatestURL = oldURL })
+ return &hits
+}
+
+func TestUpdateHintShowsOncePerDay(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ useVersion(t, "v0.7.0")
+ hits := latestServer(t, "v0.8.0")
+
+ maybeUpdateHint("ship")
+ if !strings.Contains(out.String(), "carlos v0.8.0 is available (you have v0.7.0) — run: carlos update") {
+ t.Fatalf("no hint:\n%s", out.String())
+ }
+ // Within the cache window: no second fetch, no second hint.
+ out.Reset()
+ maybeUpdateHint("ship")
+ if *hits != 1 || out.String() != "" {
+ t.Fatalf("second run: hits=%d out=%q, want cached silence", *hits, out.String())
+ }
+ // An expired cache checks again.
+ path, _ := updateCachePath()
+ stale := fmt.Sprintf(`{"checked_at": %q, "latest": "v0.8.0"}`, time.Now().Add(-25*time.Hour).Format(time.RFC3339))
+ if err := os.WriteFile(path, []byte(stale), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ maybeUpdateHint("ship")
+ if *hits != 2 {
+ t.Fatalf("hits after stale cache = %d, want 2", *hits)
+ }
+}
+
+func TestUpdateHintGates(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ useVersion(t, "v0.7.0")
+ hits := latestServer(t, "v0.8.0")
+
+ // Each gate must keep the network untouched, not merely the output.
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "some-box-bucket")
+ maybeUpdateHint("ship")
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+
+ t.Setenv("CARLOS_NO_UPDATE_CHECK", "1")
+ maybeUpdateHint("ship")
+ t.Setenv("CARLOS_NO_UPDATE_CHECK", "")
+
+ writeConfig(t, ".", `{"update_check": "off"}`)
+ maybeUpdateHint("ship")
+ os.RemoveAll(".carlos")
+
+ maybeUpdateHint("edge") // box verb
+ maybeUpdateHint("version") // parsed output
+ maybeUpdateHint("update") // the verb itself
+
+ forceTerminal(t, false)
+ maybeUpdateHint("ship")
+ forceTerminal(t, true)
+
+ useVersion(t, "dev")
+ maybeUpdateHint("ship")
+
+ if *hits != 0 || out.String() != "" {
+ t.Fatalf("gated runs: hits=%d out=%q, want zero of both", *hits, out.String())
+ }
+}
+
+func TestUpdateHintUpToDateIsSilent(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ useVersion(t, "v0.8.0")
+ hits := latestServer(t, "v0.8.0")
+
+ maybeUpdateHint("ship")
+ if *hits != 1 || out.String() != "" {
+ t.Fatalf("up to date: hits=%d out=%q, want one silent check", *hits, out.String())
+ }
+ // And the silent check still stamped the cache.
+ path, _ := updateCachePath()
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("cache not written on the up-to-date path: %v", err)
+ }
+}
+
+func TestInstallMethod(t *testing.T) {
+ for path, want := range map[string]string{
+ "/opt/homebrew/Cellar/carlos/0.7.0/bin/carlos": "brew",
+ "/home/linuxbrew/.linuxbrew/Cellar/carlos/x/carlos": "brew",
+ "/home/tom/bin/carlos": "binary",
+ } {
+ if got := installMethod(path); got != want {
+ t.Errorf("installMethod(%q) = %q, want %q", path, got, want)
+ }
+ }
+ // The dpkg branch depends on the host's dpkg database, so it is not
+ // asserted here — /usr/bin/carlos on a non-deb box is still "binary".
+}
+
+// releaseServer serves a fake carlosframework/releases download endpoint:
+// a binary, its SHA256SUMS.txt, and a signature by the given key.
+func releaseServer(t *testing.T, binary []byte, sums string, priv ed25519.PrivateKey, breakSig bool) {
+ t.Helper()
+ sig := ed25519.Sign(priv, []byte(sums))
+ if breakSig {
+ sig[0] ^= 0xff
+ }
+ asset := "carlos-" + goosArch()
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch strings.TrimPrefix(r.URL.Path, "/") {
+ case "SHA256SUMS.txt":
+ w.Write([]byte(sums))
+ case "SHA256SUMS.txt.sig":
+ w.Write([]byte(base64.StdEncoding.EncodeToString(sig)))
+ case asset:
+ w.Write(binary)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ t.Cleanup(ts.Close)
+ oldBase := updateDownloadBase
+ updateDownloadBase = ts.URL
+ t.Cleanup(func() { updateDownloadBase = oldBase })
+}
+
+func goosArch() string { return runtime.GOOS + "-" + runtime.GOARCH }
+
+func TestSelfReplace(t *testing.T) {
+ useTempCredentials(t)
+ pub, priv, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ oldKey := cliUpdatePubKey
+ cliUpdatePubKey = base64.StdEncoding.EncodeToString(pub)
+ t.Cleanup(func() { cliUpdatePubKey = oldKey })
+
+ newBinary := []byte("#!/bin/sh\necho carlos v0.8.0\n")
+ sum := sha256.Sum256(newBinary)
+ asset := fmt.Sprintf("carlos-%s", goosArch())
+ sums := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), asset)
+
+ exe := filepath.Join(t.TempDir(), "carlos")
+ if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // A broken signature refuses before anything is downloaded or touched.
+ releaseServer(t, newBinary, sums, priv, true)
+ if err := selfReplace(exe, "v0.8.0"); err == nil ||
+ !strings.Contains(err.Error(), "signature does not verify") {
+ t.Fatalf("broken sig = %v, want the signature refusal", err)
+ }
+ if got, _ := os.ReadFile(exe); string(got) != "old" {
+ t.Fatal("the binary was touched despite a bad signature")
+ }
+
+ // A checksum mismatch refuses too.
+ releaseServer(t, []byte("not what was signed"), sums, priv, false)
+ if err := selfReplace(exe, "v0.8.0"); err == nil ||
+ !strings.Contains(err.Error(), "does not match its signed checksum") {
+ t.Fatalf("bad sum = %v, want the checksum refusal", err)
+ }
+
+ // The happy path replaces atomically and keeps 0755.
+ releaseServer(t, newBinary, sums, priv, false)
+ if err := selfReplace(exe, "v0.8.0"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := os.ReadFile(exe)
+ if err != nil || string(got) != string(newBinary) {
+ t.Fatalf("replaced binary = %q, %v", got, err)
+ }
+ fi, err := os.Stat(exe)
+ if err != nil || fi.Mode().Perm() != 0o755 {
+ t.Fatalf("replaced mode = %v, %v; want 0755", fi.Mode(), err)
+ }
+}