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() mux.HandleFunc("/api/v1/repos/o/r/releases/latest", 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("/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 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, err := f.updater("v0.1.2").Check(context.Background(), exe) if err != nil { t.Fatal(err) } if !staged { t.Fatal("expected staged update") } 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 TestCheckSkipsDevBuild(t *testing.T) { f := newFakeGitea(t, "v9.9.9", []byte("new-binary")) withPublicKey(t, f.pubPEM) staged, err := f.updater("dev").Check(context.Background(), fakeExe(t)) if err != nil || staged { t.Fatalf("staged=%v err=%v, want no action for dev build", 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") } }