250 lines
6.9 KiB
Go
250 lines
6.9 KiB
Go
package update
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// fakeGitea serves a Gitea-flavored releases API with one release.
|
|
type fakeGitea struct {
|
|
srv *httptest.Server
|
|
pubPEM string
|
|
asset []byte
|
|
tag string
|
|
tamper bool
|
|
noSig bool
|
|
}
|
|
|
|
func newFakeGitea(t *testing.T, tag string, assetContent []byte) *fakeGitea {
|
|
t.Helper()
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
der, err := x509.MarshalPKIXPublicKey(pub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := &fakeGitea{
|
|
pubPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})),
|
|
asset: assetContent,
|
|
tag: tag,
|
|
}
|
|
sign := func() []byte { return ed25519.Sign(priv, f.asset) }
|
|
|
|
mux := http.NewServeMux()
|
|
serveRelease := func(w http.ResponseWriter, r *http.Request) {
|
|
assets := []map[string]string{
|
|
{"name": "gpu-turnstile.exe", "browser_download_url": f.srv.URL + "/dl/exe"},
|
|
{"name": "gpu-turnstile.exe.sha256", "browser_download_url": f.srv.URL + "/dl/sha"},
|
|
}
|
|
if !f.noSig {
|
|
assets = append(assets, map[string]string{"name": "gpu-turnstile.exe.sig", "browser_download_url": f.srv.URL + "/dl/sig"})
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"tag_name": f.tag, "assets": assets})
|
|
}
|
|
mux.HandleFunc("/api/v1/repos/o/r/releases/latest", serveRelease)
|
|
mux.HandleFunc("/api/v1/repos/o/r/releases/tags/"+f.tag, serveRelease)
|
|
mux.HandleFunc("/dl/exe", func(w http.ResponseWriter, r *http.Request) { w.Write(f.asset) })
|
|
mux.HandleFunc("/dl/sig", func(w http.ResponseWriter, r *http.Request) {
|
|
sig := sign()
|
|
if f.tamper {
|
|
sig[0] ^= 0xff
|
|
}
|
|
w.Write(sig)
|
|
})
|
|
mux.HandleFunc("/dl/sha", func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprintf(w, "%x gpu-turnstile.exe\n", sha256Bytes(f.asset))
|
|
})
|
|
f.srv = httptest.NewServer(mux)
|
|
t.Cleanup(f.srv.Close)
|
|
return f
|
|
}
|
|
|
|
func (f *fakeGitea) updater(version string) *Updater {
|
|
return &Updater{Repo: f.srv.URL + "/o/r", Asset: "gpu-turnstile.exe", Version: version}
|
|
}
|
|
|
|
func (f *fakeGitea) updaterDesired(version, desired string) *Updater {
|
|
u := f.updater(version)
|
|
u.Desired = desired
|
|
return u
|
|
}
|
|
|
|
func fakeExe(t *testing.T) string {
|
|
t.Helper()
|
|
exe := filepath.Join(t.TempDir(), "gpu-turnstile.exe")
|
|
if err := os.WriteFile(exe, []byte("old-binary"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return exe
|
|
}
|
|
|
|
func withPublicKey(t *testing.T, pem string) {
|
|
t.Helper()
|
|
old := publicKeyPEM
|
|
publicKeyPEM = pem
|
|
t.Cleanup(func() { publicKeyPEM = old })
|
|
}
|
|
|
|
func TestCheckStagesUpdate(t *testing.T) {
|
|
f := newFakeGitea(t, "v9.9.9", []byte("new-binary"))
|
|
withPublicKey(t, f.pubPEM)
|
|
exe := fakeExe(t)
|
|
|
|
staged, to, err := f.updater("v0.1.2").Check(context.Background(), exe)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !staged {
|
|
t.Fatal("expected staged update")
|
|
}
|
|
if to != "v9.9.9" {
|
|
t.Fatalf("to = %q, want v9.9.9", to)
|
|
}
|
|
content, _ := os.ReadFile(exe)
|
|
if string(content) != "new-binary" {
|
|
t.Fatalf("exe content = %q", content)
|
|
}
|
|
old, _ := os.ReadFile(exe + ".old")
|
|
if string(old) != "old-binary" {
|
|
t.Fatalf(".old content = %q", old)
|
|
}
|
|
}
|
|
|
|
func TestCheckRejectsTamperedSignature(t *testing.T) {
|
|
f := newFakeGitea(t, "v9.9.9", []byte("new-binary"))
|
|
f.tamper = true
|
|
withPublicKey(t, f.pubPEM)
|
|
exe := fakeExe(t)
|
|
|
|
staged, _, err := f.updater("v0.1.2").Check(context.Background(), exe)
|
|
if err == nil {
|
|
t.Fatal("expected signature error")
|
|
}
|
|
if staged {
|
|
t.Fatal("must not stage on bad signature")
|
|
}
|
|
content, _ := os.ReadFile(exe)
|
|
if string(content) != "old-binary" {
|
|
t.Fatal("exe was modified despite bad signature")
|
|
}
|
|
}
|
|
|
|
func TestCheckSkipsOlderOrEqual(t *testing.T) {
|
|
for _, tag := range []string{"v0.1.2", "v0.1.1", "v0.0.9"} {
|
|
f := newFakeGitea(t, tag, []byte("new-binary"))
|
|
withPublicKey(t, f.pubPEM)
|
|
staged, _, err := f.updater("v0.1.2").Check(context.Background(), fakeExe(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if staged {
|
|
t.Fatalf("tag %s must not stage over v0.1.2", tag)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckSkipsWithoutPublicKey(t *testing.T) {
|
|
f := newFakeGitea(t, "v9.9.9", []byte("new-binary"))
|
|
withPublicKey(t, "")
|
|
staged, _, err := f.updater("v0.1.2").Check(context.Background(), fakeExe(t))
|
|
if err != nil || staged {
|
|
t.Fatalf("staged=%v err=%v, want no action without key", staged, err)
|
|
}
|
|
}
|
|
|
|
func TestCheckDevBuildGetsStable(t *testing.T) {
|
|
// A dev binary cannot be compared; APP_VER=stable replaces it with the
|
|
// latest release.
|
|
f := newFakeGitea(t, "v9.9.9", []byte("new-binary"))
|
|
withPublicKey(t, f.pubPEM)
|
|
exe := fakeExe(t)
|
|
staged, _, err := f.updater("dev").Check(context.Background(), exe)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !staged {
|
|
t.Fatal("expected dev binary to be replaced by the latest release")
|
|
}
|
|
content, _ := os.ReadFile(exe)
|
|
if string(content) != "new-binary" {
|
|
t.Fatalf("exe content = %q", content)
|
|
}
|
|
}
|
|
|
|
func TestCheckDesiredDevDisables(t *testing.T) {
|
|
f := newFakeGitea(t, "v9.9.9", []byte("new-binary"))
|
|
withPublicKey(t, f.pubPEM)
|
|
for _, version := range []string{"dev", "v0.1.2"} {
|
|
staged, _, err := f.updaterDesired(version, "dev").Check(context.Background(), fakeExe(t))
|
|
if err != nil || staged {
|
|
t.Fatalf("version %s: staged=%v err=%v, want no action with APP_VER=dev", version, staged, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckPinned(t *testing.T) {
|
|
// Pin mode stages the exact tag — up or down — and skips when the
|
|
// binary already matches.
|
|
for _, version := range []string{"v0.1.2", "v9.9.9", "dev"} {
|
|
f := newFakeGitea(t, "v0.5.0", []byte("pinned-binary"))
|
|
withPublicKey(t, f.pubPEM)
|
|
exe := fakeExe(t)
|
|
staged, to, err := f.updaterDesired(version, "v0.5.0").Check(context.Background(), exe)
|
|
if err != nil {
|
|
t.Fatalf("version %s: %v", version, err)
|
|
}
|
|
if !staged {
|
|
t.Fatalf("version %s: expected pinned v0.5.0 to be staged", version)
|
|
}
|
|
if to != "v0.5.0" {
|
|
t.Fatalf("version %s: to = %q, want v0.5.0", version, to)
|
|
}
|
|
content, _ := os.ReadFile(exe)
|
|
if string(content) != "pinned-binary" {
|
|
t.Fatalf("version %s: exe content = %q", version, content)
|
|
}
|
|
}
|
|
|
|
f := newFakeGitea(t, "v0.5.0", []byte("pinned-binary"))
|
|
withPublicKey(t, f.pubPEM)
|
|
staged, _, err := f.updaterDesired("v0.5.0", "v0.5.0").Check(context.Background(), fakeExe(t))
|
|
if err != nil || staged {
|
|
t.Fatalf("staged=%v err=%v, want no action when already on the pinned version", staged, err)
|
|
}
|
|
}
|
|
|
|
func TestNewerVersion(t *testing.T) {
|
|
cases := []struct {
|
|
cur, lat string
|
|
want bool
|
|
}{
|
|
{"v0.1.2", "v0.1.3", true},
|
|
{"0.1.2", "0.2.0", true},
|
|
{"v0.1.2", "v1.0.0", true},
|
|
{"v0.1.2", "v0.1.2", false},
|
|
{"v1.2.3", "v1.2.10", true},
|
|
{"v1.2.10", "v1.2.3", false},
|
|
}
|
|
for _, c := range cases {
|
|
got, err := newerVersion(c.cur, c.lat)
|
|
if err != nil || got != c.want {
|
|
t.Errorf("newerVersion(%s, %s) = %v, %v; want %v", c.cur, c.lat, got, err, c.want)
|
|
}
|
|
}
|
|
if _, err := newerVersion("v0.1", "v0.1.2"); err == nil {
|
|
t.Error("expected error for malformed version")
|
|
}
|
|
}
|