diff --git a/cli/CLAUDE.md b/cli/CLAUDE.md index e50c55d..51ce30a 100644 --- a/cli/CLAUDE.md +++ b/cli/CLAUDE.md @@ -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--`) 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. diff --git a/cli/commands/upgrade.go b/cli/commands/upgrade.go index a15828a..b18e97c 100644 --- a/cli/commands/upgrade.go +++ b/cli/commands/upgrade.go @@ -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" @@ -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) } @@ -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 +// "␠␠"; 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 diff --git a/cli/commands/upgrade_test.go b/cli/commands/upgrade_test.go new file mode 100644 index 0000000..b6aa12a --- /dev/null +++ b/cli/commands/upgrade_test.go @@ -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") + } + }) +}