mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
1435 lines
50 KiB
Go
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")
|
|
}
|
|
}
|