Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions cli/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,5 +175,21 @@ arch-only matching and pick a wrong-OS binary → intermittent "exec format
error"), verifies against `checksums.txt`, and atomically replaces the
running binary via rename-and-hide on Windows / unlink-and-replace on Unix.

**Same-tag re-cut detection (checksum staleness).** Releases use date tags
(`vYYYY.MM.DD`) and are re-cut under the *same* tag when we ship more than once
a day, so an equal tag can point at a different published binary. A pure semver
compare would tell a morning-upgraded user "already the latest" after an
afternoon re-cut. So when the latest tag is **not strictly newer**, `orva
upgrade` also compares the running binary's SHA-256 against the published
checksum for its platform asset (`latest.AssetName` looked up in the
`latest.ValidationAssetURL` checksums file). A mismatch ⇒ a fresh build under
the same tag ⇒ reinstall. Decision lives in `upgradeAction`; the checksum probe
is `remoteBuildDiffers` (best-effort: any network/parse failure returns
`known=false` and falls back to version-only, so a flaky network never blocks or
hangs the upgrade — the fetch is bounded to 10s). Note: running `orva upgrade`
against the full server binary (`orva-<os>-<arch>`) sees a mismatch vs the CLI
asset and offers to replace it — same direction as a version bump; `orva
upgrade` is the CLI self-update path (servers update via install.sh / Docker).

If the install path is not writable, `orva upgrade` exits non-zero with
a "re-run with `sudo orva upgrade`" hint. Never silently elevates.
126 changes: 116 additions & 10 deletions cli/commands/upgrade.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
package commands

import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"runtime"
"strings"
"time"

"github.com/creativeprojects/go-selfupdate"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -84,31 +91,52 @@ func runUpgrade(cmd *cobra.Command, _ []string) error {

current := strings.TrimPrefix(Version, "v")
latestStr := latest.Version()
versionNewer := !latest.LessOrEqual(current)

// Resolve the running binary up front: we need it both to hash (staleness
// check) and to replace.
exe, err := selfupdate.ExecutablePath()
if err != nil {
return fmt.Errorf("locate running binary: %w", err)
}

// Date-based tags (vYYYY.MM.DD) get re-cut the same day, so an equal tag can
// still point at a different published binary. When the tag isn't strictly
// newer, compare the running binary's checksum against the published one for
// this platform; a mismatch means a fresh build under the same tag.
stale, known := false, false
if !versionNewer {
stale, known = remoteBuildDiffers(ctx, latest, exe)
}
rebuilt := known && stale // same tag, different published build
do := upgradeAction(versionNewer, rebuilt, force)

if check {
if latest.LessOrEqual(current) {
switch {
case versionNewer:
fmt.Fprintf(out, "orva %s is available (current: %s)\n", latestStr, Version)
case rebuilt:
fmt.Fprintf(out, "orva %s has a newer build available (same version, rebuilt) — run `orva upgrade`\n", Version)
default:
fmt.Fprintf(out, "orva %s is up to date (latest: %s)\n", Version, latestStr)
return nil
}
fmt.Fprintf(out, "orva %s is available (current: %s)\n", latestStr, Version)
return nil
}

if !force && latest.LessOrEqual(current) {
if !do {
fmt.Fprintf(out, "orva %s is already the latest.\n", Version)
return nil
}

exe, err := selfupdate.ExecutablePath()
if err != nil {
return fmt.Errorf("locate running binary: %w", err)
}

if err := assertWritable(exe); err != nil {
return fmt.Errorf("%w\nhint: re-run with `sudo orva upgrade` if the binary lives in a system path like /usr/local/bin", err)
}

fmt.Fprintf(out, "Upgrading orva %s -> %s ...\n", Version, latestStr)
if rebuilt && !versionNewer {
fmt.Fprintf(out, "Reinstalling orva %s (new build) ...\n", latestStr)
} else {
fmt.Fprintf(out, "Upgrading orva %s -> %s ...\n", Version, latestStr)
}
if err := updater.UpdateTo(ctx, latest, exe); err != nil {
return fmt.Errorf("upgrade failed: %w", err)
}
Expand All @@ -117,6 +145,84 @@ func runUpgrade(cmd *cobra.Command, _ []string) error {
return nil
}

// upgradeAction is the pure decision: do we install? A genuinely newer tag, a
// same-tag rebuild (checksum changed), or --force all trigger an install.
func upgradeAction(versionNewer, rebuilt, force bool) bool {
return force || versionNewer || rebuilt
}

// remoteBuildDiffers reports whether the latest release's published asset for
// this platform has a different checksum than the running binary. The second
// return (known) is false when we can't determine it — no validation asset, the
// asset isn't listed, or any network/parse error — so callers fall back to the
// version-only decision rather than guessing. Best-effort: never fatal.
func remoteBuildDiffers(ctx context.Context, latest *selfupdate.Release, exePath string) (differs, known bool) {
if latest == nil || latest.ValidationAssetURL == "" || latest.AssetName == "" {
return false, false
}
localSHA, err := fileSHA256(exePath)
if err != nil {
return false, false
}
remoteSHA, err := remoteAssetSHA(ctx, latest.ValidationAssetURL, latest.AssetName)
if err != nil || remoteSHA == "" {
return false, false
}
return !strings.EqualFold(localSHA, remoteSHA), true
}

// fileSHA256 returns the hex-encoded SHA-256 of the file at path.
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}

// remoteAssetSHA fetches a sha256sum-format checksums file and returns the hash
// recorded for assetName (empty string if not present). Lines look like
// "<hex>␠␠<name>"; the name may carry a leading "*" (binary-mode marker).
func remoteAssetSHA(ctx context.Context, checksumsURL, assetName string) (string, error) {
reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, checksumsURL, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("fetch checksums: status %d", resp.StatusCode)
}
return parseChecksums(resp.Body, assetName), nil
}

// parseChecksums scans a sha256sum-format stream for assetName's hash.
func parseChecksums(r io.Reader, assetName string) string {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 2 {
continue
}
name := strings.TrimPrefix(fields[len(fields)-1], "*")
if name == assetName {
return fields[0]
}
}
return ""
}

// assertWritable returns an error if the current user cannot replace the
// file at path. On Unix-likes this is an open-for-write probe; on Windows
// the running .exe is locked, but go-selfupdate handles that case via a
Expand Down
159 changes: 159 additions & 0 deletions cli/commands/upgrade_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package commands

import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/creativeprojects/go-selfupdate"
)

func sha256Hex(b []byte) string {
s := sha256.Sum256(b)
return hex.EncodeToString(s[:])
}

// TestUpgradeAction covers the install decision truth table.
func TestUpgradeAction(t *testing.T) {
cases := []struct {
versionNewer, rebuilt, force, want bool
}{
{false, false, false, false}, // up to date
{true, false, false, true}, // newer tag
{false, true, false, true}, // same tag, rebuilt
{false, false, true, true}, // forced
{true, true, true, true},
}
for _, c := range cases {
if got := upgradeAction(c.versionNewer, c.rebuilt, c.force); got != c.want {
t.Errorf("upgradeAction(newer=%v,rebuilt=%v,force=%v) = %v, want %v",
c.versionNewer, c.rebuilt, c.force, got, c.want)
}
}
}

// TestFileSHA256 checks the running-binary hasher against a known digest.
func TestFileSHA256(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "bin")
content := []byte("orva test binary contents")
if err := os.WriteFile(p, content, 0o644); err != nil {
t.Fatal(err)
}
got, err := fileSHA256(p)
if err != nil {
t.Fatalf("fileSHA256: %v", err)
}
if want := sha256Hex(content); got != want {
t.Errorf("fileSHA256 = %s, want %s", got, want)
}
if _, err := fileSHA256(filepath.Join(dir, "missing")); err == nil {
t.Error("expected error hashing a missing file")
}
}

// TestParseChecksums covers both line formats and the missing-asset case.
func TestParseChecksums(t *testing.T) {
blob := "aaaa orva-cli-linux-arm64\n" +
"bbbb orva-cli-linux-amd64\n" + // two-space (sha256sum text mode)
"cccc *orva-cli-darwin-amd64\n" // leading * (binary mode)
cases := []struct{ asset, want string }{
{"orva-cli-linux-amd64", "bbbb"},
{"orva-cli-darwin-amd64", "cccc"},
{"orva-cli-windows-amd64.exe", ""}, // absent
}
for _, c := range cases {
if got := parseChecksums(strings.NewReader(blob), c.asset); got != c.want {
t.Errorf("parseChecksums(%q) = %q, want %q", c.asset, got, c.want)
}
}
}

// TestRemoteAssetSHA fetches + parses a checksums file over HTTP.
func TestRemoteAssetSHA(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "deadbeef orva-cli-linux-amd64\n")
}))
defer srv.Close()

got, err := remoteAssetSHA(context.Background(), srv.URL, "orva-cli-linux-amd64")
if err != nil {
t.Fatalf("remoteAssetSHA: %v", err)
}
if got != "deadbeef" {
t.Errorf("remoteAssetSHA = %q, want deadbeef", got)
}

// Non-200 surfaces an error.
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer bad.Close()
if _, err := remoteAssetSHA(context.Background(), bad.URL, "x"); err == nil {
t.Error("expected error on non-200 checksums fetch")
}
}

// TestRemoteBuildDiffers exercises the staleness decision end to end against an
// httptest checksums server and a temp "binary".
func TestRemoteBuildDiffers(t *testing.T) {
dir := t.TempDir()
exe := filepath.Join(dir, "orva")
content := []byte("the running binary")
if err := os.WriteFile(exe, content, 0o755); err != nil {
t.Fatal(err)
}
localSHA := sha256Hex(content)
const asset = "orva-cli-linux-amd64"

serve := func(body string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, body)
}))
}

t.Run("equal -> not stale, known", func(t *testing.T) {
srv := serve(localSHA + " " + asset + "\n")
defer srv.Close()
rel := &selfupdate.Release{ValidationAssetURL: srv.URL, AssetName: asset}
differs, known := remoteBuildDiffers(context.Background(), rel, exe)
if !known || differs {
t.Errorf("equal sha: differs=%v known=%v, want differs=false known=true", differs, known)
}
})

t.Run("different -> stale, known", func(t *testing.T) {
srv := serve("0000000000000000000000000000000000000000000000000000000000000000 " + asset + "\n")
defer srv.Close()
rel := &selfupdate.Release{ValidationAssetURL: srv.URL, AssetName: asset}
differs, known := remoteBuildDiffers(context.Background(), rel, exe)
if !known || !differs {
t.Errorf("different sha: differs=%v known=%v, want differs=true known=true", differs, known)
}
})

t.Run("asset absent -> unknown", func(t *testing.T) {
srv := serve(localSHA + " some-other-asset\n")
defer srv.Close()
rel := &selfupdate.Release{ValidationAssetURL: srv.URL, AssetName: asset}
_, known := remoteBuildDiffers(context.Background(), rel, exe)
if known {
t.Error("absent asset: want known=false")
}
})

t.Run("no validation url -> unknown", func(t *testing.T) {
rel := &selfupdate.Release{AssetName: asset}
_, known := remoteBuildDiffers(context.Background(), rel, exe)
if known {
t.Error("no validation url: want known=false")
}
})
}
Loading