diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e18d31ad..8f510a02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,16 @@ concurrency: jobs: server-build-test: - name: Server Build & Test - runs-on: windows-latest + name: Server Build & Test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + binary: chatserver.exe + - os: ubuntu-latest + binary: chatserver + runs-on: ${{ matrix.os }} defaults: run: working-directory: Server/ @@ -27,7 +35,7 @@ jobs: cache-dependency-path: Server/go.sum - name: Build server - run: go build -o chatserver.exe -ldflags "-s -w" . + run: go build -o ${{ matrix.binary }} -ldflags "-s -w" . - name: Go vulnerability check run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 && govulncheck ./... @@ -42,7 +50,7 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: go-coverage + name: go-coverage-${{ matrix.os }} path: Server/coverage.out retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64382af4..097ea06d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,18 +6,14 @@ on: - "v*" jobs: - release: - name: Build & Release + release-client: + name: Build Tauri (Windows) runs-on: windows-latest permissions: - contents: write + contents: read steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: "1.25" - - uses: actions/setup-node@v4 with: node-version: 20 @@ -32,16 +28,6 @@ jobs: with: workspaces: Client/tauri-client/src-tauri - - name: Extract version from tag - shell: bash - run: | - VERSION="${GITHUB_REF_NAME#v}" - echo "VERSION=$VERSION" >> "$GITHUB_ENV" - - - name: Build server - shell: bash - run: cd Server && go build -o chatserver.exe -ldflags "-s -w -X main.version=$VERSION" . - - name: Install npm dependencies working-directory: Client/tauri-client run: npm ci @@ -53,62 +39,116 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: npm run tauri build - - name: Locate artifacts - id: artifacts + - name: Stage Windows release assets shell: bash run: | + mkdir -p release-staging NSIS_DIR="Client/tauri-client/src-tauri/target/release/bundle/nsis" INSTALLER=$(find "$NSIS_DIR" -name "*.exe" | head -1) - echo "installer_path=$INSTALLER" >> "$GITHUB_OUTPUT" - echo "installer_name=$(basename $INSTALLER)" >> "$GITHUB_OUTPUT" - # Updater artifacts (produced when TAURI_SIGNING_PRIVATE_KEY is set) + cp "$INSTALLER" release-staging/ NSIS_ZIP=$(find "$NSIS_DIR" -name "*_x64-setup.nsis.zip" ! -name "*.sig" | head -1) + if [ -n "$NSIS_ZIP" ] && [ -f "$NSIS_ZIP" ]; then cp "$NSIS_ZIP" release-staging/; fi NSIS_SIG=$(find "$NSIS_DIR" -name "*_x64-setup.nsis.zip.sig" | head -1) - echo "nsis_zip=${NSIS_ZIP:-}" >> "$GITHUB_OUTPUT" - echo "nsis_sig=${NSIS_SIG:-}" >> "$GITHUB_OUTPUT" + if [ -n "$NSIS_SIG" ] && [ -f "$NSIS_SIG" ]; then cp "$NSIS_SIG" release-staging/; fi + + - name: Upload Windows release assets + uses: actions/upload-artifact@v4 + with: + name: windows-release-assets + path: release-staging/ + + release-server: + name: Build server (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + artifact: server-windows + - os: ubuntu-latest + artifact: server-linux + runs-on: ${{ matrix.os }} + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Extract version from tag + shell: bash + run: | + VERSION="${GITHUB_REF_NAME#v}" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Build server (Windows) + if: matrix.os == 'windows-latest' + shell: bash + run: cd Server && go build -o chatserver.exe -ldflags "-s -w -X main.version=$VERSION" . + + - name: Build server (Linux) + if: matrix.os == 'ubuntu-latest' + working-directory: Server + env: + CGO_ENABLED: "0" + run: go build -o chatserver -ldflags "-s -w -X main.version=$VERSION" . + + - name: Create tar.gz (Linux) + if: matrix.os == 'ubuntu-latest' + working-directory: Server + run: tar czf ../chatserver-linux-amd64.tar.gz chatserver + + - name: Upload Windows binary + if: matrix.os == 'windows-latest' + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: Server/chatserver.exe + + - name: Upload Linux archive + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: chatserver-linux-amd64.tar.gz + + publish: + name: Publish GitHub Release + needs: [release-client, release-server] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download Windows client assets + uses: actions/download-artifact@v4 + with: + name: windows-release-assets + path: windows + + - name: Download Windows server binary + uses: actions/download-artifact@v4 + with: + name: server-windows + path: windows + + - name: Download Linux archive + uses: actions/download-artifact@v4 + with: + name: server-linux + path: linux - name: Generate SHA256 checksums - shell: pwsh run: | - $lines = @() - $serverHash = (Get-FileHash -Path Server/chatserver.exe -Algorithm SHA256).Hash.ToLower() - $lines += "$serverHash chatserver.exe" - $installerPath = "${{ steps.artifacts.outputs.installer_path }}" - $installerName = "${{ steps.artifacts.outputs.installer_name }}" - $clientHash = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash.ToLower() - $lines += "$clientHash $installerName" - $nsisZip = "${{ steps.artifacts.outputs.nsis_zip }}" - if ($nsisZip -and (Test-Path $nsisZip)) { - $zipName = Split-Path $nsisZip -Leaf - $zipHash = (Get-FileHash -Path $nsisZip -Algorithm SHA256).Hash.ToLower() - $lines += "$zipHash $zipName" - } - $lines -join "`n" | Out-File -FilePath checksums.sha256 -Encoding utf8 -NoNewline - - - name: Install root dependencies (changelogen) - run: npm ci - - - name: Generate changelog - shell: bash - run: npx changelogen --output CHANGELOG.md + find windows linux -type f -exec sha256sum {} \; > checksums.sha256 - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash run: | - ASSETS=( - Server/chatserver.exe - "${{ steps.artifacts.outputs.installer_path }}" - checksums.sha256 - ) - # Include updater artifacts if signing key was available - if [ -n "${{ steps.artifacts.outputs.nsis_zip }}" ]; then - ASSETS+=("${{ steps.artifacts.outputs.nsis_zip }}") - fi - if [ -n "${{ steps.artifacts.outputs.nsis_sig }}" ]; then - ASSETS+=("${{ steps.artifacts.outputs.nsis_sig }}") - fi - gh release create ${{ github.ref_name }} \ - --notes-file CHANGELOG.md \ - "${ASSETS[@]}" + mapfile -t assets < <(find windows linux -type f) + assets+=(checksums.sha256) + gh release create "${{ github.ref_name }}" \ + --generate-notes \ + "${assets[@]}" diff --git a/Server/admin/middleware_and_spawn_test.go b/Server/admin/middleware_and_spawn_test.go index adec3c98..156341f7 100644 --- a/Server/admin/middleware_and_spawn_test.go +++ b/Server/admin/middleware_and_spawn_test.go @@ -16,6 +16,7 @@ import ( "github.com/owncord/server/auth" "github.com/owncord/server/db" + "github.com/owncord/server/updater" ) // openWhiteboxTestDB opens an in-memory SQLite database for whitebox tests. @@ -419,7 +420,7 @@ func TestSpawnDetached_ValidExecutable(t *testing.T) { t.Fatalf("abs path of test binary: %v", err) } - err = spawnDetached(selfExe, []string{"-test.run=^$"}) + err = updater.SpawnDetached(selfExe, []string{"-test.run=^$"}) if err != nil { t.Errorf("spawnDetached returned error: %v", err) } @@ -428,7 +429,7 @@ func TestSpawnDetached_ValidExecutable(t *testing.T) { // TestSpawnDetached_InvalidExecutable verifies that spawnDetached returns an // error when the executable path does not exist. func TestSpawnDetached_InvalidExecutable(t *testing.T) { - err := spawnDetached("/nonexistent/path/to/binary", nil) + err := updater.SpawnDetached("/nonexistent/path/to/binary", nil) if err == nil { t.Error("expected error when executable does not exist, got nil") } @@ -448,7 +449,7 @@ func TestSpawnDetached_SetsWindowsFlags(t *testing.T) { } // Just verify it doesn't panic when setting the Windows creation flag. - err = spawnDetached(selfExe, []string{"-test.run=^$"}) + err = updater.SpawnDetached(selfExe, []string{"-test.run=^$"}) if err != nil { t.Errorf("spawnDetached on Windows returned error: %v", err) } diff --git a/Server/admin/update_handlers.go b/Server/admin/update_handlers.go index 740a967f..2f666492 100644 --- a/Server/admin/update_handlers.go +++ b/Server/admin/update_handlers.go @@ -5,9 +5,7 @@ import ( "log/slog" "net/http" "os" - "os/exec" "path/filepath" - "runtime" "syscall" "time" @@ -33,6 +31,7 @@ func handleCheckUpdate(u *updater.Updater) http.HandlerFunc { // handleApplyUpdate downloads and applies a server update. func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Handler { + //TODO: maybe disable this endpoint in future docker build type? return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if u == nil { writeErr(w, http.StatusServiceUnavailable, "UPDATE_UNAVAILABLE", "update checking is not configured") @@ -114,7 +113,7 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha } // Spawn new process. - if err := spawnDetached(exePath, os.Args[1:]); err != nil { + if err := updater.SpawnDetached(exePath, os.Args[1:]); err != nil { slog.Error("update: spawn new process failed", "error", err) return } @@ -133,18 +132,3 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha }() }) } - -// spawnDetached starts a new process that is not attached to the current one. -func spawnDetached(exePath string, args []string) error { - cmd := exec.Command(exePath, args...) //nolint:gosec // G204: command path from trusted server config - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if runtime.GOOS == "windows" { - cmd.SysProcAttr = &syscall.SysProcAttr{ - CreationFlags: 0x00000008, // DETACHED_PROCESS - } - } - - return cmd.Start() -} diff --git a/Server/admin/update_handlers_test.go b/Server/admin/update_handlers_test.go index cf1fbd14..b5d6a2d9 100644 --- a/Server/admin/update_handlers_test.go +++ b/Server/admin/update_handlers_test.go @@ -20,6 +20,7 @@ func TestAdminAPI_CheckUpdate_OK(t *testing.T) { "html_url": "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0", "assets": []map[string]any{ {"name": "chatserver.exe", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe"}, + {"name": "chatserver-linux-amd64.tar.gz", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver-linux-amd64.tar.gz"}, {"name": "checksums.sha256", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/checksums.sha256"}, }, }) @@ -264,6 +265,10 @@ func TestAdminAPI_ApplyUpdate_DownloadFails(t *testing.T) { "name": "chatserver.exe", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe", }, + { + "name": "chatserver-linux-amd64.tar.gz", + "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver-linux-amd64.tar.gz", + }, { "name": "checksums.sha256", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/checksums.sha256", diff --git a/Server/updater/proc_spawner_nix.go b/Server/updater/proc_spawner_nix.go new file mode 100644 index 00000000..bd4d1cbd --- /dev/null +++ b/Server/updater/proc_spawner_nix.go @@ -0,0 +1,21 @@ +//go:build !windows + +package updater + +import ( + "os" + "os/exec" + "syscall" +) + +// SpawnDetached starts a new process that is not attached to the current one. +func SpawnDetached(exePath string, args []string) error { + cmd := exec.Command(exePath, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + + return cmd.Start() +} diff --git a/Server/updater/proc_spawner_win.go b/Server/updater/proc_spawner_win.go new file mode 100644 index 00000000..5d44a26b --- /dev/null +++ b/Server/updater/proc_spawner_win.go @@ -0,0 +1,22 @@ +//go:build windows + +package updater + +import ( + "os" + "os/exec" + "syscall" +) + +// SpawnDetached starts a new process that is not attached to the current one. +func SpawnDetached(exePath string, args []string) error { + cmd := exec.Command(exePath, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: 0x00000008, // DETACHED_PROCESS + } + + return cmd.Start() +} diff --git a/Server/updater/updater.go b/Server/updater/updater.go index 209f6825..92c9623b 100644 --- a/Server/updater/updater.go +++ b/Server/updater/updater.go @@ -3,6 +3,8 @@ package updater import ( + "archive/tar" + "compress/gzip" "context" "crypto/sha256" "encoding/hex" @@ -13,6 +15,7 @@ import ( neturl "net/url" "os" "path/filepath" + "runtime" "strings" "time" @@ -25,8 +28,10 @@ const ( defaultBaseURL = "https://api.github.com" cacheTTL = 1 * time.Hour errorCacheTTL = 5 * time.Minute - binaryAsset = "chatserver.exe" checksumAsset = "checksums.sha256" + + windowsServerBinary = "chatserver.exe" + linuxServerArchive = "chatserver-linux-amd64.tar.gz" ) // UpdateInfo holds the result of a version check. @@ -189,12 +194,13 @@ func (u *Updater) fetchLatestRelease(ctx context.Context) (UpdateInfo, error) { var downloadURL, checksumURL string assets := make([]Asset, 0, len(release.Assets)) + wantBinary := serverDownloadAssetName(runtime.GOOS) for _, asset := range release.Assets { assets = append(assets, Asset{ Name: asset.Name, DownloadURL: asset.BrowserDownloadURL, }) - if strings.EqualFold(asset.Name, binaryAsset) { + if wantBinary != "" && strings.EqualFold(asset.Name, wantBinary) { downloadURL = asset.BrowserDownloadURL } else if strings.EqualFold(asset.Name, checksumAsset) { checksumURL = asset.BrowserDownloadURL @@ -223,9 +229,11 @@ func (u *Updater) ValidateDownloadURL(url string) error { return nil } -// DownloadAndVerify downloads the binary from downloadURL, fetches the -// checksum file from checksumURL, and verifies the SHA256 hash matches. -// On checksum mismatch the downloaded file is removed. +// DownloadAndVerify downloads the release artifact from downloadURL, fetches +// the checksum file from checksumURL, and verifies the SHA256 hash matches. +// On Windows the asset is a single executable; on Linux it is a tar.gz +// archive containing a "chatserver" binary, which is extracted to destPath. +// On checksum mismatch, partial files are removed. func (u *Updater) DownloadAndVerify(ctx context.Context, downloadURL, checksumURL, destPath string) error { if err := u.ValidateDownloadURL(downloadURL); err != nil { return err @@ -234,33 +242,168 @@ func (u *Updater) DownloadAndVerify(ctx context.Context, downloadURL, checksumUR return fmt.Errorf("validating checksum URL: %w", err) } - // Fetch checksum file. checksumData, err := u.fetchBody(ctx, checksumURL) if err != nil { return fmt.Errorf("fetching checksums: %w", err) } - destFilename := filepath.Base(destPath) - expectedHash, err := u.ParseChecksumFile(checksumData, destFilename) + goos := runtime.GOOS + names := checksumEntryNamesForGOOS(goos) + if len(names) == 0 { + return fmt.Errorf("server auto-update is not supported on %s", goos) + } + expectedHash, err := u.parseChecksumFileAny(checksumData, names...) if err != nil { return fmt.Errorf("parsing checksum file: %w", err) } - // Download the binary. + switch goos { + case "windows": + return u.downloadWindowsBinaryAndVerify(ctx, downloadURL, destPath, expectedHash) + case "linux": + return u.downloadLinuxTarballAndVerify(ctx, downloadURL, destPath, expectedHash) + default: + return fmt.Errorf("server auto-update is not supported on %s", goos) + } +} + +func (u *Updater) downloadWindowsBinaryAndVerify(ctx context.Context, downloadURL, destPath, expectedHash string) error { if err := u.downloadFile(ctx, downloadURL, destPath); err != nil { return fmt.Errorf("downloading binary: %w", err) } - - // Verify hash. if err := u.VerifyChecksum(destPath, expectedHash); err != nil { - // Remove the invalid file. _ = os.Remove(destPath) return err } - return nil } +func (u *Updater) downloadLinuxTarballAndVerify(ctx context.Context, downloadURL, destPath, expectedHash string) error { + tarPath := destPath + ".tar.gz.partial" + defer func() { _ = os.Remove(tarPath) }() + + if err := u.downloadFile(ctx, downloadURL, tarPath); err != nil { + return fmt.Errorf("downloading archive: %w", err) + } + if err := u.VerifyChecksum(tarPath, expectedHash); err != nil { + return err + } + + f, err := os.Open(tarPath) + if err != nil { + return fmt.Errorf("opening archive: %w", err) + } + defer f.Close() //nolint:errcheck + + if err := extractChatserverFromTarGz(f, destPath); err != nil { + _ = os.Remove(destPath) + return fmt.Errorf("extracting archive: %w", err) + } + if err := os.Chmod(destPath, 0o755); err != nil { + return fmt.Errorf("chmod binary: %w", err) + } + return nil +} + +func extractChatserverFromTarGz(r io.Reader, destPath string) error { + gr, err := gzip.NewReader(r) + if err != nil { + return fmt.Errorf("gzip: %w", err) + } + defer gr.Close() //nolint:errcheck + + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if err == io.EOF { + return fmt.Errorf("archive contains no file named chatserver") + } + if err != nil { + return fmt.Errorf("tar: %w", err) + } + skipBody := func() error { + if _, err := io.Copy(io.Discard, io.LimitReader(tr, hdr.Size)); err != nil { + return err + } + return nil + } + if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA { + if err := skipBody(); err != nil { + return err + } + continue + } + if strings.Contains(hdr.Name, "..") { + if err := skipBody(); err != nil { + return err + } + continue + } + if filepath.Base(hdr.Name) != "chatserver" { + if err := skipBody(); err != nil { + return err + } + continue + } + + out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + n, copyErr := io.Copy(out, io.LimitReader(tr, hdr.Size)) + closeErr := out.Close() + if copyErr != nil { + _ = os.Remove(destPath) + return fmt.Errorf("writing binary: %w", copyErr) + } + if closeErr != nil { + _ = os.Remove(destPath) + return closeErr + } + if n != hdr.Size { + _ = os.Remove(destPath) + return fmt.Errorf("incomplete tar entry (%d of %d bytes)", n, hdr.Size) + } + return nil + } +} + +// serverDownloadAssetName returns the GitHub release asset file name for the +// server binary on the given GOOS (windows, linux). Other values return "". +func serverDownloadAssetName(goos string) string { + switch goos { + case "windows": + return windowsServerBinary + case "linux": + return linuxServerArchive + default: + return "" + } +} + +// checksumEntryNamesForGOOS returns sha256sum line suffixes to look up in +// checksums.sha256 (matches GitHub Actions release layout). +func checksumEntryNamesForGOOS(goos string) []string { + switch goos { + case "windows": + return []string{"windows/chatserver.exe", "chatserver.exe"} + case "linux": + return []string{"linux/chatserver-linux-amd64.tar.gz", "chatserver-linux-amd64.tar.gz"} + default: + return nil + } +} + +func (u *Updater) parseChecksumFileAny(data []byte, names ...string) (string, error) { + for _, name := range names { + hash, err := u.ParseChecksumFile(data, name) + if err == nil { + return hash, nil + } + } + return "", fmt.Errorf("no checksum line for any of: %s", strings.Join(names, ", ")) +} + // VerifyChecksum computes the SHA256 hash of the file at filePath and // compares it (case-insensitive) against expectedHash. func (u *Updater) VerifyChecksum(filePath, expectedHash string) error { diff --git a/Server/updater/updater_test.go b/Server/updater/updater_test.go index cad273be..202a2fad 100644 --- a/Server/updater/updater_test.go +++ b/Server/updater/updater_test.go @@ -1,7 +1,9 @@ package updater import ( + "archive/tar" "bytes" + "compress/gzip" "context" "crypto/sha256" "encoding/hex" @@ -11,6 +13,8 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" + "strings" "sync/atomic" "testing" "time" @@ -37,6 +41,7 @@ func newTestRelease(tag, body, htmlURL string, assetDownloadBase string) ghRelea 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"}, }, } @@ -85,12 +90,17 @@ func TestCheckForUpdate_NewerVersionAvailable(t *testing.T) { if info.Current != "v1.0.0" { t.Errorf("expected Current=v1.0.0, got %s", info.Current) } - if info.DownloadURL == "" { - t.Error("expected non-empty DownloadURL") - } 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") + } } func TestCheckForUpdate_UpToDate(t *testing.T) { @@ -244,6 +254,96 @@ func TestVerifyChecksum_Incorrect(t *testing.T) { } } +// 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 " ". + primaryPath := names[0] + checksumData := []byte(fmt.Sprintf("%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 := []byte(fmt.Sprintf("%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") @@ -265,6 +365,68 @@ func TestParseChecksumFile_FileNotFound(t *testing.T) { } } +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") + if err := extractChatserverFromTarGz(bytes.NewReader(gzbuf.Bytes()), dest); err != nil { + t.Fatalf("extractChatserverFromTarGz: %v", err) + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, inner) { + t.Errorf("extracted content mismatch") + } +} + // ─── SetBaseURL ────────────────────────────────────────────────────────────── func TestSetBaseURL(t *testing.T) { @@ -378,6 +540,17 @@ func TestDownloadFile_NonOKStatus(t *testing.T) { // ─── 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[:]) @@ -411,13 +584,81 @@ func TestDownloadAndVerify_Success(t *testing.T) { t.Fatalf("DownloadAndVerify: %v", err) } - // File should exist and be correct. 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[:]) + + 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) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "chatserver") + + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + 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" + + u.httpClient = &http.Client{ + Transport: &rewriteTransport{srv.URL}, + } + + err := u.DownloadAndVerify(context.Background(), downloadURL, checksumURL, dest) + if err != nil { + t.Fatalf("DownloadAndVerify: %v", err) + } + + 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(), "https://evil.com/file", "https://evil.com/sum", "/tmp/out") @@ -428,7 +669,7 @@ func TestDownloadAndVerify_InvalidDownloadURL(t *testing.T) { 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" + downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/" + serverDownloadAssetName(runtime.GOOS) err := u.DownloadAndVerify(context.Background(), downloadURL, "https://evil.com/sum", "/tmp/out") if err == nil { t.Error("DownloadAndVerify should reject invalid checksum URL") @@ -436,6 +677,17 @@ func TestDownloadAndVerify_InvalidChecksumURL(t *testing.T) { } 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" @@ -469,6 +721,39 @@ func TestDownloadAndVerify_ChecksumMismatch(t *testing.T) { } } +func testDownloadAndVerifyChecksumMismatchLinux(t *testing.T) { + tgz := mustBuildChatserverTarGz(t, []byte("x")) + wrongChecksum := "0000000000000000000000000000000000000000000000000000000000000000" + + 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) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + tmpDir := t.TempDir() + dest := filepath.Join(tmpDir, "chatserver") + + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + 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" + + err := u.DownloadAndVerify(context.Background(), downloadURL, checksumURL, 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") + } +} + // rewriteTransport rewrites GitHub release URLs to a local test server. type rewriteTransport struct { target string