Files
OwnCord/Server/updater/updater_test.go
T

1435 lines
50 KiB
Go

package updater
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"aead.dev/minisign"
)
// ghRelease mirrors the GitHub release API response shape.
type ghRelease struct {
TagName string `json:"tag_name"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
Assets []ghAsset `json:"assets"`
}
// ghAsset mirrors a GitHub release asset.
type ghAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
func newTestRelease(tag, body, htmlURL string, assetDownloadBase string) ghRelease {
return ghRelease{
TagName: tag,
Body: body,
HTMLURL: htmlURL,
Assets: []ghAsset{
{Name: "chatserver.exe", BrowserDownloadURL: assetDownloadBase + "/chatserver.exe"},
{Name: "chatserver-linux-amd64.tar.gz", BrowserDownloadURL: assetDownloadBase + "/chatserver-linux-amd64.tar.gz"},
{Name: "checksums.sha256", BrowserDownloadURL: assetDownloadBase + "/checksums.sha256"},
{Name: "chatserver.exe.sig", BrowserDownloadURL: assetDownloadBase + "/chatserver.exe.sig"},
{Name: "server-update-manifest.json", BrowserDownloadURL: assetDownloadBase + "/server-update-manifest.json"},
{Name: "server-update-manifest.json.sig", BrowserDownloadURL: assetDownloadBase + "/server-update-manifest.json.sig"},
},
}
}
func newTestServer(t *testing.T, release ghRelease, statusCode int) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
if statusCode == http.StatusOK {
if err := json.NewEncoder(w).Encode(release); err != nil {
t.Fatalf("encoding release: %v", err)
}
} else {
_, _ = fmt.Fprint(w, `{"message":"Internal Server Error"}`)
}
})
return httptest.NewServer(mux)
}
func newTestUpdater(baseURL, currentVersion string) *Updater {
u := NewUpdater(currentVersion, "", "J3vb", "OwnCord")
u.baseURL = baseURL
return u
}
func newSignedTestUpdater(t *testing.T, baseURL, currentVersion string) (*Updater, minisign.PrivateKey) {
t.Helper()
publicKey, privateKey, err := minisign.GenerateKey(nil)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
publicKeyText, err := publicKey.MarshalText()
if err != nil {
t.Fatalf("MarshalText(public key): %v", err)
}
u := newTestUpdater(baseURL, currentVersion)
u.signingKeyText = base64.StdEncoding.EncodeToString(publicKeyText)
return u, privateKey
}
func signTestAsset(t *testing.T, privateKey minisign.PrivateKey, content []byte) []byte {
t.Helper()
reader := minisign.NewReader(bytes.NewReader(content))
if _, err := io.Copy(io.Discard, reader); err != nil {
t.Fatalf("signTestAsset io.Copy: %v", err)
}
return reader.SignWithComments(privateKey, "timestamp:1712016000\tfile:chatserver.exe", "untrusted comment: owncord test")
}
func TestCheckForUpdate_NewerVersionAvailable(t *testing.T) {
release := newTestRelease("v1.2.0", "Bug fixes and improvements", "https://github.com/J3vb/OwnCord/releases/tag/v1.2.0",
"https://github.com/J3vb/OwnCord/releases/download/v1.2.0")
srv := newTestServer(t, release, http.StatusOK)
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
info, err := u.CheckForUpdate(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !info.UpdateAvailable {
t.Error("expected UpdateAvailable=true, got false")
}
if info.Latest != "v1.2.0" {
t.Errorf("expected Latest=v1.2.0, got %s", info.Latest)
}
if info.Current != "v1.0.0" {
t.Errorf("expected Current=v1.0.0, got %s", info.Current)
}
if info.ChecksumURL == "" {
t.Error("expected non-empty ChecksumURL")
}
// Server binary asset is only selected on Windows and Linux.
if want := serverDownloadAssetName(runtime.GOOS); want != "" {
if info.DownloadURL == "" {
t.Error("expected non-empty DownloadURL")
}
} else if info.DownloadURL != "" {
t.Error("expected empty DownloadURL on unsupported GOOS")
}
if info.SignatureURL == "" {
t.Error("expected non-empty SignatureURL")
}
if info.ManifestURL == "" {
t.Error("expected non-empty ManifestURL")
}
if info.ManifestSignatureURL == "" {
t.Error("expected non-empty ManifestSignatureURL")
}
if !info.RequiredAssetsPresent {
t.Error("expected RequiredAssetsPresent=true")
}
}
func TestCheckForUpdate_MissingRequiredAssetsSuppressesUpdate(t *testing.T) {
release := ghRelease{
TagName: "v1.2.0",
Body: "Broken release",
HTMLURL: "https://github.com/J3vb/OwnCord/releases/tag/v1.2.0",
Assets: []ghAsset{
{Name: "chatserver.exe", BrowserDownloadURL: "https://github.com/J3vb/OwnCord/releases/download/v1.2.0/chatserver.exe"},
{Name: "checksums.sha256", BrowserDownloadURL: "https://github.com/J3vb/OwnCord/releases/download/v1.2.0/checksums.sha256"},
},
}
srv := newTestServer(t, release, http.StatusOK)
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
info, err := u.CheckForUpdate(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info.UpdateAvailable {
t.Fatal("expected UpdateAvailable=false for incomplete release")
}
if info.RequiredAssetsPresent {
t.Fatal("expected RequiredAssetsPresent=false for incomplete release")
}
}
func TestCheckForUpdate_UpToDate(t *testing.T) {
release := newTestRelease("v1.0.0", "Current release", "https://github.com/J3vb/OwnCord/releases/tag/v1.0.0",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0")
srv := newTestServer(t, release, http.StatusOK)
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
info, err := u.CheckForUpdate(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info.UpdateAvailable {
t.Error("expected UpdateAvailable=false, got true")
}
}
func TestCheckForUpdate_CachesResult(t *testing.T) {
var hitCount atomic.Int32
release := newTestRelease("v2.0.0", "Major update", "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0",
"https://github.com/J3vb/OwnCord/releases/download/v2.0.0")
mux := http.NewServeMux()
mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) {
hitCount.Add(1)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(release); err != nil {
t.Fatalf("encoding release: %v", err)
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
ctx := context.Background()
_, err := u.CheckForUpdate(ctx)
if err != nil {
t.Fatalf("first call error: %v", err)
}
_, err = u.CheckForUpdate(ctx)
if err != nil {
t.Fatalf("second call error: %v", err)
}
if got := hitCount.Load(); got != 1 {
t.Errorf("expected 1 API hit (cached), got %d", got)
}
}
func TestCheckForUpdate_CacheExpires(t *testing.T) {
var hitCount atomic.Int32
release := newTestRelease("v2.0.0", "Major update", "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0",
"https://github.com/J3vb/OwnCord/releases/download/v2.0.0")
mux := http.NewServeMux()
mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) {
hitCount.Add(1)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(release); err != nil {
t.Fatalf("encoding release: %v", err)
}
})
srv := httptest.NewServer(mux)
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
ctx := context.Background()
// First call populates cache.
_, err := u.CheckForUpdate(ctx)
if err != nil {
t.Fatalf("first call error: %v", err)
}
// Expire the cache manually.
u.mu.Lock()
u.cacheExpiry = time.Now().Add(-1 * time.Minute)
u.mu.Unlock()
// Second call should hit the API again.
_, err = u.CheckForUpdate(ctx)
if err != nil {
t.Fatalf("second call error: %v", err)
}
if got := hitCount.Load(); got != 2 {
t.Errorf("expected 2 API hits (cache expired), got %d", got)
}
}
func TestCheckForUpdate_APIError(t *testing.T) {
release := ghRelease{} // unused since status is 500
srv := newTestServer(t, release, http.StatusInternalServerError)
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
_, err := u.CheckForUpdate(context.Background())
if err == nil {
t.Fatal("expected error for 500 response, got nil")
}
}
func TestValidateDownloadURL_Valid(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
err := u.ValidateDownloadURL("https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe")
if err != nil {
t.Errorf("expected valid URL to pass, got error: %v", err)
}
}
func TestValidateDownloadURL_Invalid(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
err := u.ValidateDownloadURL("https://evil.com/chatserver.exe")
if err == nil {
t.Error("expected invalid URL to be rejected, got nil")
}
}
func TestVerifyChecksum_Correct(t *testing.T) {
content := []byte("hello world binary content")
hash := sha256.Sum256(content)
expectedHash := hex.EncodeToString(hash[:])
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "chatserver.exe")
if err := os.WriteFile(filePath, content, 0o644); err != nil {
t.Fatalf("writing temp file: %v", err)
}
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
if err := u.VerifyChecksum(filePath, expectedHash); err != nil {
t.Errorf("expected correct checksum to pass, got error: %v", err)
}
}
func TestVerifyChecksum_Incorrect(t *testing.T) {
content := []byte("hello world binary content")
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "chatserver.exe")
if err := os.WriteFile(filePath, content, 0o644); err != nil {
t.Fatalf("writing temp file: %v", err)
}
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
wrongHash := "0000000000000000000000000000000000000000000000000000000000000000"
if err := u.VerifyChecksum(filePath, wrongHash); err == nil {
t.Error("expected incorrect checksum to fail, got nil")
}
}
// TestUpdateChecksum_SHA256MatchesChecksumsFile checks the full checksum chain
// used during server update: SHA-256 of the downloaded release artifact must
// equal the hex in checksums.sha256 (same layout as CI: "hash path/to/file"),
// and VerifyChecksum must accept the on-disk file against that expected value.
func TestUpdateChecksum_SHA256MatchesChecksumsFile(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
tests := []struct {
name string
goos string
assetFn func(*testing.T) []byte
}{
{
name: "windows_exe",
goos: "windows",
assetFn: func(*testing.T) []byte {
return []byte("windows server binary payload for checksum test")
},
},
{
name: "linux_tar_gz",
goos: "linux",
assetFn: func(t *testing.T) []byte {
return mustBuildChatserverTarGz(t, []byte("linux inner binary for checksum test"))
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
asset := tc.assetFn(t)
names := checksumEntryNamesForGOOS(tc.goos)
if len(names) == 0 {
t.Fatal("checksumEntryNamesForGOOS: empty names")
}
sum := sha256.Sum256(asset)
expectedHex := hex.EncodeToString(sum[:])
// Same line shape as release workflow: sha256sum prints "<hash> <path>".
primaryPath := names[0]
checksumData := fmt.Appendf(nil, "%s %s\n", expectedHex, primaryPath)
parsed, err := u.parseChecksumFileAny(checksumData, names...)
if err != nil {
t.Fatalf("parseChecksumFileAny: %v", err)
}
if !strings.EqualFold(parsed, expectedHex) {
t.Fatalf("parsed hash %q, want %q", parsed, expectedHex)
}
tmp := filepath.Join(t.TempDir(), "release-asset")
if err := os.WriteFile(tmp, asset, 0o644); err != nil {
t.Fatal(err)
}
if err := u.VerifyChecksum(tmp, expectedHex); err != nil {
t.Fatalf("VerifyChecksum: %v", err)
}
})
}
}
// TestUpdateChecksum_FallbackChecksumLine verifies lookup when checksums.sha256
// lists only the bare filename (second candidate in checksumEntryNamesForGOOS).
func TestUpdateChecksum_FallbackChecksumLine(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
asset := []byte("bare-name-line test")
sum := sha256.Sum256(asset)
expectedHex := hex.EncodeToString(sum[:])
// Only "chatserver.exe", no windows/ prefix — second entry in list must match.
checksumData := fmt.Appendf(nil, "%s chatserver.exe\n", expectedHex)
names := checksumEntryNamesForGOOS("windows")
parsed, err := u.parseChecksumFileAny(checksumData, names...)
if err != nil {
t.Fatalf("parseChecksumFileAny: %v", err)
}
if !strings.EqualFold(parsed, expectedHex) {
t.Fatalf("parsed %q, want %q", parsed, expectedHex)
}
tmp := filepath.Join(t.TempDir(), "chatserver.exe")
if err := os.WriteFile(tmp, asset, 0o644); err != nil {
t.Fatal(err)
}
if err := u.VerifyChecksum(tmp, expectedHex); err != nil {
t.Fatalf("VerifyChecksum: %v", err)
}
}
func TestParseChecksumFile_FindsFile(t *testing.T) {
data := []byte("abc123 readme.txt\ndef456 chatserver.exe\nghi789 other.dll\n")
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
hash, err := u.ParseChecksumFile(data, "chatserver.exe")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hash != "def456" {
t.Errorf("expected hash=def456, got %s", hash)
}
}
func TestParseChecksumFile_FileNotFound(t *testing.T) {
data := []byte("abc123 readme.txt\ndef456 chatserver.exe\n")
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
_, err := u.ParseChecksumFile(data, "nonexistent.exe")
if err == nil {
t.Error("expected error for missing file in checksum data, got nil")
}
}
func TestParseChecksumFileAny_FirstMatch(t *testing.T) {
data := []byte("aaa other\nbbb linux/chatserver-linux-amd64.tar.gz\nccc chatserver.exe\n")
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
h, err := u.parseChecksumFileAny(data, "linux/chatserver-linux-amd64.tar.gz", "chatserver.exe")
if err != nil {
t.Fatalf("parseChecksumFileAny: %v", err)
}
if h != "bbb" {
t.Errorf("hash = %q, want bbb (linux line first in list)", h)
}
}
func TestServerDownloadAssetName(t *testing.T) {
tests := []struct {
goos string
want string
}{
{"windows", "chatserver.exe"},
{"linux", "chatserver-linux-amd64.tar.gz"},
{"darwin", ""},
{"freebsd", ""},
}
for _, tc := range tests {
if got := serverDownloadAssetName(tc.goos); got != tc.want {
t.Errorf("serverDownloadAssetName(%q) = %q, want %q", tc.goos, got, tc.want)
}
}
}
func TestExtractChatserverFromTarGz(t *testing.T) {
inner := []byte("#!/bin/fake\n")
var gzbuf bytes.Buffer
gw := gzip.NewWriter(&gzbuf)
tw := tar.NewWriter(gw)
hdr := &tar.Header{Name: "chatserver", Mode: 0o755, Size: int64(len(inner)), Typeflag: tar.TypeReg}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(inner); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gw.Close(); err != nil {
t.Fatal(err)
}
tmpDir := t.TempDir()
dest := filepath.Join(tmpDir, "chatserver")
gotHash, err := extractChatserverFromTarGz(bytes.NewReader(gzbuf.Bytes()), dest)
if err != nil {
t.Fatalf("extractChatserverFromTarGz: %v", err)
}
innerSum := sha256.Sum256(inner)
if gotHash != hex.EncodeToString(innerSum[:]) {
t.Errorf("extracted hash = %q, want hash of written bytes", gotHash)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, inner) {
t.Errorf("extracted content mismatch")
}
}
// tarEntry is one member of a test archive; body is empty for header-only
// entries such as symlinks.
type tarEntry struct {
hdr tar.Header
body []byte
}
func buildTarGz(t *testing.T, entries ...tarEntry) []byte {
t.Helper()
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
for i := range entries {
hdr := entries[i].hdr
hdr.Mode = 0o755
hdr.Size = int64(len(entries[i].body))
if err := tw.WriteHeader(&hdr); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(entries[i].body); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestExtractChatserverFromTarGzEntryFilters(t *testing.T) {
want := []byte("#!/bin/real\n")
wantSum := sha256.Sum256(want)
regular := tarEntry{hdr: tar.Header{Name: "chatserver", Typeflag: tar.TypeReg}, body: want}
tests := []struct {
name string
entries []tarEntry
wantErr bool
}{
{
// A "chatserver" shipped as a symlink must not be followed.
name: "non-regular entry is skipped",
entries: []tarEntry{{hdr: tar.Header{Name: "chatserver", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd"}}, regular},
},
{
name: "path-traversal name is skipped",
entries: []tarEntry{{hdr: tar.Header{Name: "../../chatserver", Typeflag: tar.TypeReg}, body: []byte("planted")}, regular},
},
{
name: "other basename is skipped",
entries: []tarEntry{{hdr: tar.Header{Name: "chatserver.sig", Typeflag: tar.TypeReg}, body: []byte("sig")}, regular},
},
{
name: "no chatserver member at all",
entries: []tarEntry{{hdr: tar.Header{Name: "README", Typeflag: tar.TypeReg}, body: []byte("hi")}, {hdr: tar.Header{Name: "chatserver", Typeflag: tar.TypeSymlink, Linkname: "/bin/sh"}}},
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dest := filepath.Join(t.TempDir(), "chatserver")
gotHash, err := extractChatserverFromTarGz(bytes.NewReader(buildTarGz(t, tc.entries...)), dest)
if tc.wantErr {
if err == nil {
t.Fatalf("extractChatserverFromTarGz = %q, want error", gotHash)
}
if _, statErr := os.Stat(dest); !errors.Is(statErr, fs.ErrNotExist) {
t.Errorf("destPath exists after a failed extraction (stat err %v)", statErr)
}
return
}
if err != nil {
t.Fatalf("extractChatserverFromTarGz: %v", err)
}
if gotHash != hex.EncodeToString(wantSum[:]) {
t.Errorf("hash = %q, want hash of the regular chatserver entry", gotHash)
}
got, readErr := os.ReadFile(dest)
if readErr != nil {
t.Fatal(readErr)
}
if !bytes.Equal(got, want) {
t.Errorf("extracted %q, want %q", got, want)
}
})
}
}
// A pre-existing staging path is an attacker-planted file: staging is O_EXCL,
// so extraction must fail rather than write through it.
func TestExtractChatserverFromTarGzRefusesExistingDest(t *testing.T) {
dest := filepath.Join(t.TempDir(), "chatserver")
planted := []byte("planted-by-attacker")
if err := os.WriteFile(dest, planted, 0o600); err != nil {
t.Fatal(err)
}
archive := buildTarGz(t, tarEntry{hdr: tar.Header{Name: "chatserver", Typeflag: tar.TypeReg}, body: []byte("#!/bin/real\n")})
if _, err := extractChatserverFromTarGz(bytes.NewReader(archive), dest); !errors.Is(err, fs.ErrExist) {
t.Fatalf("extractChatserverFromTarGz err = %v, want fs.ErrExist", err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, planted) {
t.Errorf("pre-existing file was overwritten: %q", got)
}
}
func TestAssetFilenameFromURL(t *testing.T) {
got, err := assetFilenameFromURL("https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe")
if err != nil {
t.Fatalf("assetFilenameFromURL: %v", err)
}
if got != "chatserver.exe" {
t.Errorf("assetFilenameFromURL = %q, want chatserver.exe", got)
}
}
func TestDefaultServerSignaturePublicKey_DiffersFromTauriUpdaterKey(t *testing.T) {
tauriConfigPath := filepath.Clean(filepath.Join("..", "..", "Client", "src-tauri", "tauri.conf.json"))
raw, err := os.ReadFile(tauriConfigPath)
if err != nil {
t.Fatalf("ReadFile(%s): %v", tauriConfigPath, err)
}
var cfg struct {
Plugins struct {
Updater struct {
PubKey string `json:"pubkey"`
} `json:"updater"`
} `json:"plugins"`
}
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatalf("Unmarshal tauri.conf.json: %v", err)
}
if cfg.Plugins.Updater.PubKey == defaultServerSignaturePublicKey {
t.Fatalf("server updater signing key must differ from tauri.conf.json updater pubkey")
}
}
func TestDefaultServerSignaturePublicKey_Parseable(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
if _, err := u.serverSignaturePublicKey(); err != nil {
t.Fatalf("serverSignaturePublicKey: %v", err)
}
}
// ─── SetBaseURL ──────────────────────────────────────────────────────────────
func TestSetBaseURL(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
u.SetBaseURL("https://custom.api.example.com")
if u.apiBaseURL() != "https://custom.api.example.com" {
t.Errorf("apiBaseURL = %q, want custom URL", u.apiBaseURL())
}
}
func TestApiBaseURL_DefaultWhenEmpty(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
got := u.apiBaseURL()
if got != defaultBaseURL {
t.Errorf("apiBaseURL = %q, want default %q", got, defaultBaseURL)
}
}
// ─── fetchBody ───────────────────────────────────────────────────────────────
func TestFetchBody_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("hello body"))
}))
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
body, err := u.fetchBody(context.Background(), srv.URL+"/test")
if err != nil {
t.Fatalf("fetchBody: %v", err)
}
if string(body) != "hello body" {
t.Errorf("body = %q, want 'hello body'", body)
}
}
func TestFetchBody_NonOKStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
_, err := u.fetchBody(context.Background(), srv.URL+"/test")
if err == nil {
t.Error("fetchBody should error on non-200 status")
}
}
func TestFetchBody_WithGithubToken(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
defer srv.Close()
u := NewUpdater("1.0.0", "my-token", "J3vb", "OwnCord")
u.baseURL = srv.URL
_, _ = u.fetchBody(context.Background(), srv.URL+"/test")
if gotAuth != "token my-token" {
t.Errorf("Authorization = %q, want 'token my-token'", gotAuth)
}
}
// ─── downloadFile ────────────────────────────────────────────────────────────
func TestDownloadFile_Success(t *testing.T) {
content := []byte("binary content here")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write(content)
}))
defer srv.Close()
tmpDir := t.TempDir()
dest := filepath.Join(tmpDir, "downloaded.exe")
u := newTestUpdater(srv.URL, "1.0.0")
if err := u.downloadFile(context.Background(), srv.URL+"/binary", dest); err != nil {
t.Fatalf("downloadFile: %v", err)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("reading downloaded file: %v", err)
}
if !bytes.Equal(got, content) {
t.Errorf("content = %q, want %q", got, content)
}
}
func TestDownloadFile_NonOKStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
tmpDir := t.TempDir()
dest := filepath.Join(tmpDir, "downloaded.exe")
u := newTestUpdater(srv.URL, "1.0.0")
err := u.downloadFile(context.Background(), srv.URL+"/binary", dest)
if err == nil {
t.Error("downloadFile should error on non-200 status")
}
}
// ─── DownloadAndVerify ───────────────────────────────────────────────────────
func TestDownloadAndVerify_Success(t *testing.T) {
switch runtime.GOOS {
case "windows":
testDownloadAndVerifySuccessWindows(t)
case "linux":
testDownloadAndVerifySuccessLinux(t)
default:
t.Skip("no DownloadAndVerify integration case for GOOS=" + runtime.GOOS)
}
}
func testDownloadAndVerifySuccessWindows(t *testing.T) {
content := []byte("real binary content for verification")
hash := sha256.Sum256(content)
checksumHex := hex.EncodeToString(hash[:])
u, privateKey := newSignedTestUpdater(t, "", "1.0.0")
signature := signTestAsset(t, privateKey, content)
manifest := []byte(`{"version":"v1.0.0","asset":"chatserver.exe","sha256":"` + checksumHex + `"}`)
manifestSignature := signTestAsset(t, privateKey, manifest)
mux := http.NewServeMux()
mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(content)
})
mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "%s chatserver.exe\n", checksumHex)
})
mux.HandleFunc("/download/chatserver.exe.sig", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(append(signature, []byte("\r\n")...))
})
mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(manifest)
})
mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(manifestSignature)
})
srv := httptest.NewServer(mux)
defer srv.Close()
tmpDir := t.TempDir()
dest := filepath.Join(tmpDir, "chatserver.exe.new")
u.baseURL = srv.URL
downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe"
checksumURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256"
signatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe.sig"
manifestURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json"
manifestSignatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig"
// Override HTTP client to route GitHub URLs to our test server.
u.httpClient = &http.Client{
Transport: &rewriteTransport{srv.URL},
}
stagedHash, err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, dest)
if err != nil {
t.Fatalf("DownloadAndVerify: %v", err)
}
if stagedHash != checksumHex {
t.Errorf("staged hash = %q, want manifest-bound hash %q", stagedHash, checksumHex)
}
got, _ := os.ReadFile(dest)
if !bytes.Equal(got, content) {
t.Errorf("downloaded content mismatch")
}
}
func testDownloadAndVerifySuccessLinux(t *testing.T) {
inner := []byte("linux binary payload")
tgz := mustBuildChatserverTarGz(t, inner)
hash := sha256.Sum256(tgz)
checksumHex := hex.EncodeToString(hash[:])
u, privateKey := newSignedTestUpdater(t, "", "1.0.0")
manifest := []byte(`{"version":"v1.0.0","asset":"chatserver-linux-amd64.tar.gz","sha256":"` + checksumHex + `"}`)
manifestSignature := signTestAsset(t, privateKey, manifest)
mux := http.NewServeMux()
mux.HandleFunc("/download/chatserver-linux-amd64.tar.gz", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(tgz)
})
mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "%s linux/chatserver-linux-amd64.tar.gz\n", checksumHex)
})
mux.HandleFunc("/download/chatserver-linux-amd64.tar.gz.sig", func(w http.ResponseWriter, r *http.Request) {
// Linux tar.gz does not have a detached binary sig; return empty to satisfy URL validation.
// The signing flow only applies the manifest; the binary sig slot is unused on Linux.
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(manifest)
})
mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(manifestSignature)
})
srv := httptest.NewServer(mux)
defer srv.Close()
tmpDir := t.TempDir()
dest := filepath.Join(tmpDir, "chatserver")
u.baseURL = srv.URL
downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver-linux-amd64.tar.gz"
checksumURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256"
signatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver-linux-amd64.tar.gz.sig"
manifestURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json"
manifestSignatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig"
u.httpClient = &http.Client{
Transport: &rewriteTransport{srv.URL},
}
stagedHash, err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, dest)
if err != nil {
t.Fatalf("DownloadAndVerify: %v", err)
}
innerSum := sha256.Sum256(inner)
if stagedHash != hex.EncodeToString(innerSum[:]) {
t.Errorf("staged hash = %q, want hash of extracted binary", stagedHash)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, inner) {
t.Errorf("extracted binary mismatch")
}
}
func mustBuildChatserverTarGz(t *testing.T, inner []byte) []byte {
t.Helper()
var gzbuf bytes.Buffer
gw := gzip.NewWriter(&gzbuf)
tw := tar.NewWriter(gw)
hdr := &tar.Header{
Name: "chatserver",
Mode: 0o755,
Size: int64(len(inner)),
Typeflag: tar.TypeReg,
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(inner); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gw.Close(); err != nil {
t.Fatal(err)
}
return gzbuf.Bytes()
}
func TestDownloadAndVerify_InvalidDownloadURL(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
_, err := u.DownloadAndVerify(context.Background(), "v1.0.0", "https://evil.com/file", "https://evil.com/sum", "https://evil.com/file.sig", "https://evil.com/manifest.json", "https://evil.com/manifest.json.sig", "/tmp/out")
if err == nil {
t.Error("DownloadAndVerify should reject invalid download URL")
}
}
func TestDownloadAndVerify_InvalidChecksumURL(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe"
_, err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, "https://evil.com/sum", "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe.sig", "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json", "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig", "/tmp/out")
if err == nil {
t.Error("DownloadAndVerify should reject invalid checksum URL")
}
}
func TestDownloadAndVerify_ChecksumMismatch(t *testing.T) {
switch runtime.GOOS {
case "windows":
testDownloadAndVerifyChecksumMismatchWindows(t)
case "linux":
testDownloadAndVerifyChecksumMismatchLinux(t)
default:
t.Skip("no checksum mismatch case for GOOS=" + runtime.GOOS)
}
}
func testDownloadAndVerifyChecksumMismatchWindows(t *testing.T) {
content := []byte("binary content")
wrongChecksum := "0000000000000000000000000000000000000000000000000000000000000000"
actualHash := sha256.Sum256(content)
actualChecksum := hex.EncodeToString(actualHash[:])
u, privateKey := newSignedTestUpdater(t, "", "1.0.0")
signature := signTestAsset(t, privateKey, content)
manifest := []byte(`{"version":"v1.0.0","asset":"chatserver.exe","sha256":"` + actualChecksum + `"}`)
manifestSignature := signTestAsset(t, privateKey, manifest)
mux := http.NewServeMux()
mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(content)
})
mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "%s chatserver.exe\n", wrongChecksum)
})
mux.HandleFunc("/download/chatserver.exe.sig", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(signature)
})
mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(manifest)
})
mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(manifestSignature)
})
srv := httptest.NewServer(mux)
defer srv.Close()
tmpDir := t.TempDir()
dest := filepath.Join(tmpDir, "chatserver.exe")
u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}}
downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe"
checksumURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256"
signatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe.sig"
manifestURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json"
manifestSignatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig"
_, err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, dest)
if err == nil {
t.Error("DownloadAndVerify should fail on checksum mismatch")
}
// File should be removed after mismatch.
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
t.Error("file should be removed after checksum mismatch")
}
}
func testDownloadAndVerifyChecksumMismatchLinux(t *testing.T) {
tgz := mustBuildChatserverTarGz(t, []byte("x"))
wrongChecksum := "0000000000000000000000000000000000000000000000000000000000000000"
actualHash := sha256.Sum256(tgz)
actualChecksum := hex.EncodeToString(actualHash[:])
u, privateKey := newSignedTestUpdater(t, "", "1.0.0")
manifest := []byte(`{"version":"v1.0.0","asset":"chatserver-linux-amd64.tar.gz","sha256":"` + actualChecksum + `"}`)
manifestSignature := signTestAsset(t, privateKey, manifest)
mux := http.NewServeMux()
mux.HandleFunc("/download/chatserver-linux-amd64.tar.gz", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(tgz)
})
mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "%s linux/chatserver-linux-amd64.tar.gz\n", wrongChecksum)
})
mux.HandleFunc("/download/chatserver-linux-amd64.tar.gz.sig", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(manifest)
})
mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(manifestSignature)
})
srv := httptest.NewServer(mux)
defer srv.Close()
tmpDir := t.TempDir()
dest := filepath.Join(tmpDir, "chatserver")
u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}}
downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver-linux-amd64.tar.gz"
checksumURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256"
signatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver-linux-amd64.tar.gz.sig"
manifestURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json"
manifestSignatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig"
_, err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, dest)
if err == nil {
t.Error("DownloadAndVerify should fail on checksum mismatch")
}
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
t.Error("extracted file should not exist after checksum mismatch")
}
}
func TestDownloadAndVerify_MissingSignature(t *testing.T) {
content := []byte("real binary content for verification")
hash := sha256.Sum256(content)
checksumHex := hex.EncodeToString(hash[:])
u, privateKey := newSignedTestUpdater(t, "", "1.0.0")
manifest := []byte(`{"version":"v1.0.0","asset":"chatserver.exe","sha256":"` + checksumHex + `"}`)
manifestSignature := signTestAsset(t, privateKey, manifest)
mux := http.NewServeMux()
mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(content)
})
mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "%s chatserver.exe\n", checksumHex)
})
mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(manifest)
})
mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(manifestSignature)
})
srv := httptest.NewServer(mux)
defer srv.Close()
tmpDir := t.TempDir()
dest := filepath.Join(tmpDir, "chatserver.exe")
u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}}
_, err := u.DownloadAndVerify(
context.Background(),
"v1.0.0",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe.sig",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig",
dest,
)
if err == nil {
t.Fatal("DownloadAndVerify should fail when signature asset is missing")
}
}
func TestDownloadAndVerify_InvalidSignature(t *testing.T) {
content := []byte("real binary content for verification")
otherContent := []byte("tampered bytes")
hash := sha256.Sum256(content)
checksumHex := hex.EncodeToString(hash[:])
u, privateKey := newSignedTestUpdater(t, "", "1.0.0")
signature := signTestAsset(t, privateKey, otherContent)
manifest := []byte(`{"version":"v1.0.0","asset":"chatserver.exe","sha256":"` + checksumHex + `"}`)
manifestSignature := signTestAsset(t, privateKey, manifest)
mux := http.NewServeMux()
mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(content)
})
mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "%s chatserver.exe\n", checksumHex)
})
mux.HandleFunc("/download/chatserver.exe.sig", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(signature)
})
mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(manifest)
})
mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(manifestSignature)
})
srv := httptest.NewServer(mux)
defer srv.Close()
tmpDir := t.TempDir()
dest := filepath.Join(tmpDir, "chatserver.exe")
u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}}
_, err := u.DownloadAndVerify(
context.Background(),
"v1.0.0",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe.sig",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig",
dest,
)
if err == nil {
t.Fatal("DownloadAndVerify should fail on invalid signature")
}
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
t.Error("file should be removed after signature verification failure")
}
}
func TestDownloadAndVerify_MalformedSignature(t *testing.T) {
content := []byte("real binary content for verification")
hash := sha256.Sum256(content)
checksumHex := hex.EncodeToString(hash[:])
u, privateKey := newSignedTestUpdater(t, "", "1.0.0")
manifest := []byte(`{"version":"v1.0.0","asset":"chatserver.exe","sha256":"` + checksumHex + `"}`)
manifestSignature := signTestAsset(t, privateKey, manifest)
mux := http.NewServeMux()
mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(content)
})
mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "%s chatserver.exe\n", checksumHex)
})
mux.HandleFunc("/download/chatserver.exe.sig", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("not-a-valid-signature"))
})
mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(manifest)
})
mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(manifestSignature)
})
srv := httptest.NewServer(mux)
defer srv.Close()
tmpDir := t.TempDir()
dest := filepath.Join(tmpDir, "chatserver.exe")
u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}}
_, err := u.DownloadAndVerify(
context.Background(),
"v1.0.0",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe.sig",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig",
dest,
)
if err == nil {
t.Fatal("DownloadAndVerify should fail on malformed signature")
}
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
t.Error("file should be removed after malformed signature")
}
}
func TestDownloadAndVerify_ManifestVersionMismatch(t *testing.T) {
content := []byte("real binary content for verification")
hash := sha256.Sum256(content)
checksumHex := hex.EncodeToString(hash[:])
u, privateKey := newSignedTestUpdater(t, "", "1.0.0")
signature := signTestAsset(t, privateKey, content)
manifest := []byte(`{"version":"v0.9.0","asset":"chatserver.exe","sha256":"` + checksumHex + `"}`)
manifestSignature := signTestAsset(t, privateKey, manifest)
mux := http.NewServeMux()
mux.HandleFunc("/download/chatserver.exe", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(content)
})
mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "%s chatserver.exe\n", checksumHex)
})
mux.HandleFunc("/download/chatserver.exe.sig", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(signature)
})
mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(manifest)
})
mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(manifestSignature)
})
srv := httptest.NewServer(mux)
defer srv.Close()
dest := filepath.Join(t.TempDir(), "chatserver.exe")
u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}}
_, err := u.DownloadAndVerify(
context.Background(),
"v1.0.0",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe.sig",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json",
"https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig",
dest,
)
if err == nil {
t.Fatal("DownloadAndVerify should fail on mismatched signed manifest version")
}
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
t.Error("file should be removed after manifest verification failure")
}
}
// rewriteTransport rewrites GitHub release URLs to a local test server.
type rewriteTransport struct {
target string
}
func (rt *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Rewrite github.com URLs to the test server.
newURL := rt.target + "/download/" + filepath.Base(req.URL.Path)
newReq, _ := http.NewRequestWithContext(req.Context(), req.Method, newURL, req.Body)
return http.DefaultTransport.RoundTrip(newReq)
}
// TestVerifySignature_TauriBase64WrappedFormat locks in support for the .sig
// format that `tauri signer sign` produces in the release pipeline: a
// base64-wrapped minisign document. Raw minisign documents must keep working.
func TestVerifySignature_TauriBase64WrappedFormat(t *testing.T) {
u, privateKey := newSignedTestUpdater(t, "", "1.0.0")
content := []byte("tauri wrapped signature test")
rawSig := signTestAsset(t, privateKey, content)
wrappedSig := []byte(base64.StdEncoding.EncodeToString(rawSig))
path := filepath.Join(t.TempDir(), "chatserver.exe")
if err := os.WriteFile(path, content, 0o600); err != nil {
t.Fatalf("writing test asset: %v", err)
}
if err := u.VerifySignature(path, wrappedSig); err != nil {
t.Errorf("base64-wrapped tauri signature should verify: %v", err)
}
if err := u.VerifySignature(path, rawSig); err != nil {
t.Errorf("raw minisign signature should verify: %v", err)
}
// Valid base64 that decodes to garbage must fail cleanly, not verify.
garbage := []byte(base64.StdEncoding.EncodeToString([]byte("not a signature")))
if err := u.VerifySignature(path, garbage); err == nil {
t.Error("garbage base64 signature should fail verification")
}
// Wrapped signature over different content must fail verification.
otherPath := filepath.Join(t.TempDir(), "other.bin")
if err := os.WriteFile(otherPath, []byte("tampered"), 0o600); err != nil {
t.Fatalf("writing tampered asset: %v", err)
}
if err := u.VerifySignature(otherPath, wrappedSig); err == nil {
t.Error("wrapped signature must not verify tampered content")
}
}
// ─── Staged-binary TOCTOU guard (W3-3) ───────────────────────────────────────
// TestOpenVerifiedBinary_CommitHappyPath: verify-through-handle then commit
// moves the exact verified file to the destination.
func TestOpenVerifiedBinary_CommitHappyPath(t *testing.T) {
dir := t.TempDir()
stagedPath := filepath.Join(dir, "app.new")
content := []byte("verified update bytes")
if err := os.WriteFile(stagedPath, content, 0o600); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(content)
staged, err := OpenVerifiedBinary(stagedPath, hex.EncodeToString(sum[:]))
if err != nil {
t.Fatalf("OpenVerifiedBinary: %v", err)
}
defer staged.Close() //nolint:errcheck
destPath := filepath.Join(dir, "app")
if err := staged.Commit(destPath); err != nil {
t.Fatalf("Commit: %v", err)
}
got, err := os.ReadFile(destPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, content) {
t.Errorf("committed content mismatch")
}
if _, err := os.Lstat(stagedPath); !os.IsNotExist(err) {
t.Errorf("staged path should be gone after commit")
}
}
func TestOpenVerifiedBinary_WrongHash(t *testing.T) {
stagedPath := filepath.Join(t.TempDir(), "app.new")
if err := os.WriteFile(stagedPath, []byte("some bytes"), 0o600); err != nil {
t.Fatal(err)
}
wrong := "0000000000000000000000000000000000000000000000000000000000000000"
if _, err := OpenVerifiedBinary(stagedPath, wrong); err == nil {
t.Fatal("OpenVerifiedBinary must reject a hash mismatch")
}
}
// TestOpenVerifiedBinary_SwapAfterVerifyDetectedAtCommit locks the W3-3
// invariant: hash verification and the rename commit operate on the same
// file. The old path-based flow (VerifyChecksum(path) then os.Rename(path))
// silently renamed — and would have spawned — whatever was swapped in at the
// staged path after verification. Now either the swap itself is blocked by
// the held verification handle (Windows) or Commit detects the swapped file
// and refuses (Unix); unverified bytes must never land at the destination.
func TestOpenVerifiedBinary_SwapAfterVerifyDetectedAtCommit(t *testing.T) {
dir := t.TempDir()
stagedPath := filepath.Join(dir, "app.new")
good := []byte("good verified bytes")
if err := os.WriteFile(stagedPath, good, 0o600); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(good)
staged, err := OpenVerifiedBinary(stagedPath, hex.EncodeToString(sum[:]))
if err != nil {
t.Fatalf("OpenVerifiedBinary: %v", err)
}
defer staged.Close() //nolint:errcheck
// Attacker tries to win the race: replace the staged path after
// verification but before the rename.
evilPath := filepath.Join(dir, "evil")
if err := os.WriteFile(evilPath, []byte("malicious payload"), 0o600); err != nil {
t.Fatal(err)
}
swapErr := os.Rename(evilPath, stagedPath)
destPath := filepath.Join(dir, "app")
commitErr := staged.Commit(destPath)
switch {
case swapErr == nil && commitErr == nil:
t.Fatal("staged binary was swapped after verification and Commit did not detect it")
case swapErr != nil && commitErr != nil:
t.Fatalf("swap was blocked (%v) but Commit still failed: %v", swapErr, commitErr)
case commitErr == nil:
// Swap blocked by the held verification handle (Windows): the
// committed file must be the verified bytes.
got, err := os.ReadFile(destPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, good) {
t.Errorf("committed content is not the verified bytes")
}
}
}
// TestDownloadFile_RefusesPreExistingDest locks the O_EXCL staging invariant:
// the download must refuse to write through a path an attacker pre-created.
func TestDownloadFile_RefusesPreExistingDest(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("downloaded content"))
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "staged.bin")
planted := []byte("attacker planted file")
if err := os.WriteFile(dest, planted, 0o600); err != nil {
t.Fatal(err)
}
u := newTestUpdater(srv.URL, "1.0.0")
if err := u.downloadFile(context.Background(), srv.URL+"/binary", dest); err == nil {
t.Fatal("downloadFile must refuse a pre-existing staging path")
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, planted) {
t.Errorf("pre-existing file must be left untouched")
}
}
// The chatserver.exe.sig asset is consumed only by the Windows verify path;
// Linux integrity comes entirely from the signed manifest + checksum. Gating
// linux updates on a Windows-only asset makes any release without it report
// UpdateAvailable=false on Linux forever.
func TestHasRequiredServerAssets_SignatureGOOSAware(t *testing.T) {
if !hasRequiredServerAssetsFor("linux", "d", "c", "", "m", "ms") {
t.Error("linux update gated on the windows-only .sig asset")
}
if hasRequiredServerAssetsFor("windows", "d", "c", "", "m", "ms") {
t.Error("windows update must still hard-require the .sig asset")
}
if !hasRequiredServerAssetsFor("windows", "d", "c", "s", "m", "ms") {
t.Error("complete windows asset set refused")
}
}