Implement internal/updater: selfupdate via Forgejo Releases API

Adds a small, well-tested package that:
- Queries /api/v1/repos/root/drover-go/releases/latest (404 = no updates,
  not an error).
- Compares the published tag against the running Version using
  golang.org/x/mod/semver, so v0.1.0-rc.2 < v0.1.0. "dev" or any
  semver-invalid current version is treated as "always update".
- Downloads the windows-amd64 asset + SHA256SUMS.txt, verifies the
  sha256 of the binary against its line in the sums file (tolerates
  the asterisk binary-mode prefix), and atomically swaps the running
  exe via github.com/minio/selfupdate.
- Uses a 15s connect timeout with no overall request deadline, so
  large asset downloads aren't truncated.
- Reports progress via an optional callback.

Public surface: Source interface + ForgejoSource implementation,
CheckForUpdate, ApplyUpdate, SetVersion. No GUI/cobra/Wails imports
in the package, so the same code is reusable from the CLI, the
Windows service, and the future tray UI.

Wires the package into "drover update" / "drover update --check-only"
in cmd/drover/main.go. --check-only exits 0 whether or not an update
is available; only network/sha/apply errors are non-zero.

Tests cover CheckForUpdate (table-driven incl. semver pre-release
ordering, dev fallthrough, source errors), parseSHA256Sums (text and
binary modes, CRLF, malformed lines, missing entries),
ForgejoSource.Latest (httptest with canned JSON, 404, 500, missing
asset, missing SHA256SUMS), and downloadAndVerify (success, sha
mismatch, HTTP 404, context cancellation). All run with -race.

Smoke-tested manually: built drover.exe and "drover update --check-only"
against git.okcu.io prints "No updates available" and exits 0 (no
releases yet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 00:20:24 +03:00
parent 25df64213c
commit 1ad8de32f2
6 changed files with 1155 additions and 5 deletions
+612
View File
@@ -0,0 +1,612 @@
package updater
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// fakeSource is an in-memory Source implementation for tests.
type fakeSource struct {
rel *Release
err error
}
func (f *fakeSource) Latest(ctx context.Context) (*Release, error) {
if f.err != nil {
return nil, f.err
}
return f.rel, nil
}
// ---------------------------------------------------------------------------
// CheckForUpdate
// ---------------------------------------------------------------------------
func TestCheckForUpdate(t *testing.T) {
t.Parallel()
rel := func(tag string) *Release {
return &Release{TagName: tag, Name: tag, AssetURL: "u", SumsURL: "s"}
}
sentinel := errors.New("network down")
tests := []struct {
name string
current string
src Source
wantHasUpdate bool
wantNilRelease bool
wantErrIs error
}{
{
name: "newer release available",
current: "0.1.0",
src: &fakeSource{rel: rel("v0.2.0")},
wantHasUpdate: true,
},
{
name: "newer release with v-prefixed current version",
current: "v0.1.0",
src: &fakeSource{rel: rel("v0.2.0")},
wantHasUpdate: true,
},
{
name: "same version — no update",
current: "0.2.0",
src: &fakeSource{rel: rel("v0.2.0")},
wantHasUpdate: false,
wantNilRelease: true,
},
{
name: "older release on remote — no update",
current: "0.5.0",
src: &fakeSource{rel: rel("v0.2.0")},
wantHasUpdate: false,
wantNilRelease: true,
},
{
name: "rc < final (semver pre-release ordering)",
current: "0.1.0-rc.2",
src: &fakeSource{rel: rel("v0.1.0")},
wantHasUpdate: true,
},
{
name: "final >= rc",
current: "0.1.0",
src: &fakeSource{rel: rel("v0.1.0-rc.2")},
wantHasUpdate: false,
wantNilRelease: true,
},
{
name: "dev current — always update",
current: "dev",
src: &fakeSource{rel: rel("v0.1.0")},
wantHasUpdate: true,
},
{
name: "garbage current — always update (treated as invalid)",
current: "not-a-version",
src: &fakeSource{rel: rel("v0.1.0")},
wantHasUpdate: true,
},
{
name: "source returns nil release — no update",
current: "0.1.0",
src: &fakeSource{rel: nil},
wantHasUpdate: false,
wantNilRelease: true,
},
{
name: "source error propagates wrapped",
current: "0.1.0",
src: &fakeSource{err: sentinel},
wantErrIs: sentinel,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
got, has, err := CheckForUpdate(ctx, tc.src, tc.current)
if tc.wantErrIs != nil {
if err == nil {
t.Fatalf("expected error wrapping %v, got nil", tc.wantErrIs)
}
if !errors.Is(err, tc.wantErrIs) {
t.Fatalf("err = %v; expected to wrap %v", err, tc.wantErrIs)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if has != tc.wantHasUpdate {
t.Fatalf("hasUpdate = %v; want %v", has, tc.wantHasUpdate)
}
if tc.wantNilRelease && got != nil {
t.Fatalf("expected nil release; got %+v", got)
}
if !tc.wantNilRelease && tc.wantHasUpdate && got == nil {
t.Fatalf("expected non-nil release on update; got nil")
}
})
}
}
// ---------------------------------------------------------------------------
// parseSHA256Sums
// ---------------------------------------------------------------------------
func TestParseSHA256Sums(t *testing.T) {
t.Parallel()
const a = "8da085332782708d8767bcace5327a6ec7283c17cfb85e40b03cd2323a90ddc2"
const b = "c1e060ee19444a259b2162f8af0f3fe8c4428a1c6f694dce20de194ac8d7d9a2"
tests := []struct {
name string
input string
want string
wantName string
wantErr bool
}{
{
name: "standard text-mode line",
input: a + " drover-v0.1.0-windows-amd64.exe\n",
want: a,
wantName: "drover-v0.1.0-windows-amd64.exe",
},
{
name: "binary-mode asterisk prefix",
input: b + " *drover-v0.1.0-setup.exe\n",
want: b,
wantName: "drover-v0.1.0-setup.exe",
},
{
name: "multi-line, target on second",
input: a + " some-other-file.txt\n" +
b + " drover-v0.1.0-setup.exe\n",
want: b,
wantName: "drover-v0.1.0-setup.exe",
},
{
name: "multi-line, blank lines and CRLF tolerated",
input: "\r\n" +
a + " drover-v0.1.0-windows-amd64.exe\r\n" +
"\n",
want: a,
wantName: "drover-v0.1.0-windows-amd64.exe",
},
{
name: "missing entry",
input: a + " some-other-file.txt\n",
wantName: "drover-v0.1.0-windows-amd64.exe",
wantErr: true,
},
{
name: "malformed line — no separator",
input: a + "drover-v0.1.0-windows-amd64.exe\n",
wantName: "drover-v0.1.0-windows-amd64.exe",
wantErr: true,
},
{
name: "malformed line — bad hex length",
input: "deadbeef drover-v0.1.0-windows-amd64.exe\n",
wantName: "drover-v0.1.0-windows-amd64.exe",
wantErr: true,
},
{
name: "empty input",
input: "",
wantName: "drover-v0.1.0-windows-amd64.exe",
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := parseSHA256Sums([]byte(tc.input), tc.wantName)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error; got hash %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("hash = %q; want %q", got, tc.want)
}
})
}
}
// ---------------------------------------------------------------------------
// ForgejoSource.Latest
// ---------------------------------------------------------------------------
const sampleReleaseJSON = `{
"tag_name": "v0.1.0",
"name": "v0.1.0",
"draft": false,
"prerelease": false,
"created_at": "2026-04-25T13:35:33+03:00",
"assets": [
{
"name": "drover-v0.1.0-windows-amd64.exe",
"size": 5800000,
"browser_download_url": "%s/drover-v0.1.0-windows-amd64.exe"
},
{
"name": "SHA256SUMS.txt",
"size": 200,
"browser_download_url": "%s/SHA256SUMS.txt"
}
]
}`
func TestForgejoSource_Latest_Success(t *testing.T) {
t.Parallel()
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/root/drover-go/releases/latest" {
t.Errorf("unexpected request path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
if ua := r.Header.Get("User-Agent"); !strings.HasPrefix(ua, "drover-go/") {
t.Errorf("expected User-Agent drover-go/...; got %q", ua)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, sampleReleaseJSON, srv.URL, srv.URL)
}))
defer srv.Close()
src := &ForgejoSource{
baseURL: srv.URL,
owner: "root",
repo: "drover-go",
assetPattern: "windows-amd64.exe",
client: srv.Client(),
userAgent: "drover-go/test",
}
rel, err := src.Latest(context.Background())
if err != nil {
t.Fatalf("Latest err = %v", err)
}
if rel == nil {
t.Fatal("nil release returned")
}
if rel.TagName != "v0.1.0" {
t.Errorf("TagName = %q; want v0.1.0", rel.TagName)
}
wantAsset := srv.URL + "/drover-v0.1.0-windows-amd64.exe"
if rel.AssetURL != wantAsset {
t.Errorf("AssetURL = %q; want %q", rel.AssetURL, wantAsset)
}
wantSums := srv.URL + "/SHA256SUMS.txt"
if rel.SumsURL != wantSums {
t.Errorf("SumsURL = %q; want %q", rel.SumsURL, wantSums)
}
if rel.Prerelease {
t.Error("Prerelease = true; want false")
}
if rel.CreatedAt.IsZero() {
t.Error("CreatedAt is zero")
}
}
func TestForgejoSource_Latest_404_NoUpdates(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
defer srv.Close()
src := &ForgejoSource{
baseURL: srv.URL,
owner: "root",
repo: "drover-go",
assetPattern: "windows-amd64.exe",
client: srv.Client(),
userAgent: "drover-go/test",
}
rel, err := src.Latest(context.Background())
if err != nil {
t.Fatalf("expected nil error on 404; got %v", err)
}
if rel != nil {
t.Fatalf("expected nil release on 404; got %+v", rel)
}
}
func TestForgejoSource_Latest_500_Error(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer srv.Close()
src := &ForgejoSource{
baseURL: srv.URL,
owner: "root",
repo: "drover-go",
assetPattern: "windows-amd64.exe",
client: srv.Client(),
userAgent: "drover-go/test",
}
_, err := src.Latest(context.Background())
if err == nil {
t.Fatal("expected error on 500; got nil")
}
if !strings.Contains(err.Error(), "500") {
t.Errorf("expected error to mention HTTP 500; got %v", err)
}
}
func TestForgejoSource_Latest_AssetMissing(t *testing.T) {
t.Parallel()
// Sample with only SHA256SUMS, no exe asset.
body := `{
"tag_name": "v0.1.0",
"name": "v0.1.0",
"draft": false,
"prerelease": false,
"created_at": "2026-04-25T13:35:33+03:00",
"assets": [
{"name":"SHA256SUMS.txt","size":1,"browser_download_url":"https://example.invalid/s"}
]
}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, body)
}))
defer srv.Close()
src := &ForgejoSource{
baseURL: srv.URL,
owner: "root",
repo: "drover-go",
assetPattern: "windows-amd64.exe",
client: srv.Client(),
userAgent: "drover-go/test",
}
_, err := src.Latest(context.Background())
if err == nil {
t.Fatal("expected error when matching asset is missing; got nil")
}
}
func TestForgejoSource_Latest_SumsMissing(t *testing.T) {
t.Parallel()
body := `{
"tag_name": "v0.1.0",
"name": "v0.1.0",
"draft": false,
"prerelease": false,
"created_at": "2026-04-25T13:35:33+03:00",
"assets": [
{"name":"drover-v0.1.0-windows-amd64.exe","size":1,"browser_download_url":"https://example.invalid/x"}
]
}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, body)
}))
defer srv.Close()
src := &ForgejoSource{
baseURL: srv.URL,
owner: "root",
repo: "drover-go",
assetPattern: "windows-amd64.exe",
client: srv.Client(),
userAgent: "drover-go/test",
}
_, err := src.Latest(context.Background())
if err == nil {
t.Fatal("expected error when SHA256SUMS.txt is missing; got nil")
}
if !strings.Contains(err.Error(), "SHA256SUMS.txt") {
t.Errorf("expected error to mention SHA256SUMS.txt; got %v", err)
}
}
func TestNewForgejoSource_BuildsURL(t *testing.T) {
t.Parallel()
src := NewForgejoSource("git.okcu.io", "root", "drover-go", "windows-amd64.exe").(*ForgejoSource)
if want := "https://git.okcu.io"; src.baseURL != want {
t.Errorf("baseURL = %q; want %q", src.baseURL, want)
}
if src.owner != "root" || src.repo != "drover-go" {
t.Errorf("owner/repo wrong: %s/%s", src.owner, src.repo)
}
if src.assetPattern != "windows-amd64.exe" {
t.Errorf("assetPattern = %q", src.assetPattern)
}
if src.client == nil {
t.Error("client must not be nil")
}
if !strings.HasPrefix(src.userAgent, "drover-go/") {
t.Errorf("userAgent = %q; want drover-go/...", src.userAgent)
}
}
// ---------------------------------------------------------------------------
// downloadAndVerify (the testable extract from ApplyUpdate)
// ---------------------------------------------------------------------------
func TestDownloadAndVerify_Success(t *testing.T) {
t.Parallel()
exeData := []byte("MZ\x90fake-windows-binary-content")
hash := sha256.Sum256(exeData)
hexHash := hex.EncodeToString(hash[:])
exeName := "drover-v0.1.0-windows-amd64.exe"
sumsBody := hexHash + " " + exeName + "\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/exe":
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(exeData)))
_, _ = w.Write(exeData)
case "/sums":
_, _ = io.WriteString(w, sumsBody)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
rel := &Release{
TagName: "v0.1.0",
Name: "v0.1.0",
AssetURL: srv.URL + "/exe",
SumsURL: srv.URL + "/sums",
}
var pcDownloaded, pcTotal int64
bin, err := downloadAndVerify(context.Background(), rel, exeName, srv.Client(), "drover-go/test", func(d, total int64) {
pcDownloaded = d
pcTotal = total
})
if err != nil {
t.Fatalf("downloadAndVerify err = %v", err)
}
if !equalBytes(bin, exeData) {
t.Fatalf("downloaded bytes do not match source")
}
if pcTotal != int64(len(exeData)) {
t.Errorf("progress total = %d; want %d", pcTotal, len(exeData))
}
if pcDownloaded != int64(len(exeData)) {
t.Errorf("progress downloaded final = %d; want %d", pcDownloaded, len(exeData))
}
}
func TestDownloadAndVerify_SHAMismatch(t *testing.T) {
t.Parallel()
exeData := []byte("real-binary-bytes")
exeName := "drover-v0.1.0-windows-amd64.exe"
wrongHash := strings.Repeat("0", 64)
sumsBody := wrongHash + " " + exeName + "\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/exe":
_, _ = w.Write(exeData)
case "/sums":
_, _ = io.WriteString(w, sumsBody)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
rel := &Release{
TagName: "v0.1.0",
Name: "v0.1.0",
AssetURL: srv.URL + "/exe",
SumsURL: srv.URL + "/sums",
}
_, err := downloadAndVerify(context.Background(), rel, exeName, srv.Client(), "drover-go/test", nil)
if err == nil {
t.Fatal("expected SHA mismatch error; got nil")
}
if !strings.Contains(err.Error(), "sha256") && !strings.Contains(err.Error(), "checksum") {
t.Errorf("expected error to mention sha256/checksum; got %v", err)
}
}
func TestDownloadAndVerify_AssetHTTP404(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
defer srv.Close()
rel := &Release{
TagName: "v0.1.0",
AssetURL: srv.URL + "/exe",
SumsURL: srv.URL + "/sums",
}
_, err := downloadAndVerify(context.Background(), rel, "drover-v0.1.0-windows-amd64.exe", srv.Client(), "drover-go/test", nil)
if err == nil {
t.Fatal("expected error on 404; got nil")
}
}
func TestDownloadAndVerify_ContextCanceled(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Block forever — handler unblocks when client closes connection
// (which happens when the test's ctx is canceled).
select {
case <-time.After(5 * time.Second):
case <-r.Context().Done():
}
}))
defer srv.Close()
rel := &Release{AssetURL: srv.URL + "/exe", SumsURL: srv.URL + "/sums"}
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
_, err := downloadAndVerify(ctx, rel, "x", srv.Client(), "drover-go/test", nil)
if err == nil {
t.Fatal("expected error from canceled context; got nil")
}
}
// equalBytes is here to avoid a bytes import for one comparison.
func equalBytes(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// Sanity check that the Forgejo schema fields we care about decode correctly.
func TestReleaseJSONShape(t *testing.T) {
t.Parallel()
var rel forgejoRelease
body := fmt.Sprintf(sampleReleaseJSON, "https://example.invalid", "https://example.invalid")
if err := json.Unmarshal([]byte(body), &rel); err != nil {
t.Fatalf("decode err: %v", err)
}
if len(rel.Assets) != 2 {
t.Fatalf("got %d assets; want 2", len(rel.Assets))
}
}