feat: split client tooling and add OAuth server login
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
// Package gamedata validates the versioned archive served to the client.
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ArchiveName = "common-dbdata.bin"
|
||||
InfoName = "common-dbdata.info"
|
||||
)
|
||||
|
||||
// Result describes a completely readable GameData archive. Validation reads
|
||||
// every entry to EOF, which makes archive/zip verify each entry's CRC32.
|
||||
type Result struct {
|
||||
ArchivePath string
|
||||
ArchiveSize int64
|
||||
EntryCount int
|
||||
UncompressedSize int64
|
||||
}
|
||||
|
||||
// Ensure returns a verified local archive. If local validation fails, it
|
||||
// downloads the official size metadata and archive into staging files,
|
||||
// validates the complete ZIP (including every entry CRC), and only then
|
||||
// replaces the bad files. Existing files are preserved as .broken.* backups.
|
||||
func Ensure(ctx context.Context, client *http.Client, root, version, origin string) (Result, bool, error) {
|
||||
if result, err := Validate(root, version); err == nil {
|
||||
return result, false, nil
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 30 * time.Minute}
|
||||
}
|
||||
origin = strings.TrimRight(origin, "/")
|
||||
if origin == "" {
|
||||
return Result{}, false, fmt.Errorf("gamedata: download origin is empty")
|
||||
}
|
||||
release := filepath.Join(root, version, "release")
|
||||
if err := os.MkdirAll(release, 0o755); err != nil {
|
||||
return Result{}, false, fmt.Errorf("gamedata: create release directory: %w", err)
|
||||
}
|
||||
baseURL := origin + "/" + version + "/release/"
|
||||
|
||||
infoBytes, err := downloadSmall(ctx, client, baseURL+InfoName, 1024)
|
||||
if err != nil {
|
||||
return Result{}, false, err
|
||||
}
|
||||
expected, err := parseExpectedSize(infoBytes)
|
||||
if err != nil {
|
||||
return Result{}, false, err
|
||||
}
|
||||
|
||||
archiveStage, err := os.CreateTemp(release, ".common-dbdata-*.tmp")
|
||||
if err != nil {
|
||||
return Result{}, false, fmt.Errorf("gamedata: create archive staging file: %w", err)
|
||||
}
|
||||
archiveStagePath := archiveStage.Name()
|
||||
keepStage := false
|
||||
defer func() {
|
||||
_ = archiveStage.Close()
|
||||
if !keepStage {
|
||||
_ = os.Remove(archiveStagePath)
|
||||
}
|
||||
}()
|
||||
if err := downloadExact(ctx, client, baseURL+ArchiveName, archiveStage, expected); err != nil {
|
||||
return Result{}, false, err
|
||||
}
|
||||
if err := archiveStage.Sync(); err != nil {
|
||||
return Result{}, false, fmt.Errorf("gamedata: sync staged archive: %w", err)
|
||||
}
|
||||
if err := archiveStage.Close(); err != nil {
|
||||
return Result{}, false, fmt.Errorf("gamedata: close staged archive: %w", err)
|
||||
}
|
||||
verified, err := validateArchive(archiveStagePath, expected)
|
||||
if err != nil {
|
||||
return Result{}, false, fmt.Errorf("gamedata: downloaded archive failed validation: %w", err)
|
||||
}
|
||||
|
||||
infoStage, err := os.CreateTemp(release, ".common-dbdata-info-*.tmp")
|
||||
if err != nil {
|
||||
return Result{}, false, fmt.Errorf("gamedata: create info staging file: %w", err)
|
||||
}
|
||||
infoStagePath := infoStage.Name()
|
||||
defer os.Remove(infoStagePath)
|
||||
if _, err := infoStage.Write(infoBytes); err != nil {
|
||||
infoStage.Close()
|
||||
return Result{}, false, fmt.Errorf("gamedata: write staged info: %w", err)
|
||||
}
|
||||
if err := infoStage.Sync(); err != nil {
|
||||
infoStage.Close()
|
||||
return Result{}, false, fmt.Errorf("gamedata: sync staged info: %w", err)
|
||||
}
|
||||
if err := infoStage.Close(); err != nil {
|
||||
return Result{}, false, fmt.Errorf("gamedata: close staged info: %w", err)
|
||||
}
|
||||
|
||||
archivePath := filepath.Join(release, ArchiveName)
|
||||
infoPath := filepath.Join(release, InfoName)
|
||||
stamp := time.Now().UTC().Format("20060102T150405.000000000Z")
|
||||
archiveBackup, archiveMoved, err := preserveExisting(archivePath, stamp)
|
||||
if err != nil {
|
||||
return Result{}, false, err
|
||||
}
|
||||
infoBackup, infoMoved, err := preserveExisting(infoPath, stamp)
|
||||
if err != nil {
|
||||
if archiveMoved {
|
||||
_ = os.Rename(archiveBackup, archivePath)
|
||||
}
|
||||
return Result{}, false, err
|
||||
}
|
||||
rollback := func() {
|
||||
_ = os.Remove(archivePath)
|
||||
_ = os.Remove(infoPath)
|
||||
if archiveMoved {
|
||||
_ = os.Rename(archiveBackup, archivePath)
|
||||
}
|
||||
if infoMoved {
|
||||
_ = os.Rename(infoBackup, infoPath)
|
||||
}
|
||||
}
|
||||
if err := os.Rename(archiveStagePath, archivePath); err != nil {
|
||||
rollback()
|
||||
return Result{}, false, fmt.Errorf("gamedata: install verified archive: %w", err)
|
||||
}
|
||||
keepStage = true
|
||||
if err := os.Rename(infoStagePath, infoPath); err != nil {
|
||||
rollback()
|
||||
return Result{}, false, fmt.Errorf("gamedata: install size metadata: %w", err)
|
||||
}
|
||||
|
||||
verified.ArchivePath = archivePath
|
||||
return verified, true, nil
|
||||
}
|
||||
|
||||
func Validate(root, version string) (Result, error) {
|
||||
if root == "" || version == "" || strings.ContainsAny(version, `/\\`) {
|
||||
return Result{}, fmt.Errorf("gamedata: invalid root or version")
|
||||
}
|
||||
release := filepath.Join(root, version, "release")
|
||||
archivePath := filepath.Join(release, ArchiveName)
|
||||
infoPath := filepath.Join(release, InfoName)
|
||||
|
||||
infoBytes, err := os.ReadFile(infoPath)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("gamedata: read size metadata: %w", err)
|
||||
}
|
||||
expected, err := parseExpectedSize(infoBytes)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
stat, err := os.Stat(archivePath)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("gamedata: stat archive: %w", err)
|
||||
}
|
||||
if stat.Size() != expected {
|
||||
return Result{}, fmt.Errorf("gamedata: archive size %d does not match metadata %d", stat.Size(), expected)
|
||||
}
|
||||
|
||||
return validateArchive(archivePath, expected)
|
||||
}
|
||||
|
||||
func validateArchive(archivePath string, expected int64) (Result, error) {
|
||||
stat, err := os.Stat(archivePath)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("gamedata: stat archive: %w", err)
|
||||
}
|
||||
if stat.Size() != expected {
|
||||
return Result{}, fmt.Errorf("gamedata: archive size %d does not match metadata %d", stat.Size(), expected)
|
||||
}
|
||||
reader, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("gamedata: open archive: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
if len(reader.File) == 0 {
|
||||
return Result{}, fmt.Errorf("gamedata: archive is empty")
|
||||
}
|
||||
|
||||
var unpacked int64
|
||||
for index, entry := range reader.File {
|
||||
stream, err := entry.Open()
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("gamedata: open entry %d %q: %w", index, entry.Name, err)
|
||||
}
|
||||
read, copyErr := io.Copy(io.Discard, stream)
|
||||
closeErr := stream.Close()
|
||||
if copyErr != nil {
|
||||
return Result{}, fmt.Errorf("gamedata: verify entry %d %q: %w", index, entry.Name, copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return Result{}, fmt.Errorf("gamedata: close entry %d %q: %w", index, entry.Name, closeErr)
|
||||
}
|
||||
if read != int64(entry.UncompressedSize64) {
|
||||
return Result{}, fmt.Errorf("gamedata: entry %d %q size %d, expected %d", index, entry.Name, read, entry.UncompressedSize64)
|
||||
}
|
||||
unpacked += read
|
||||
}
|
||||
|
||||
return Result{
|
||||
ArchivePath: archivePath,
|
||||
ArchiveSize: stat.Size(),
|
||||
EntryCount: len(reader.File),
|
||||
UncompressedSize: unpacked,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseExpectedSize(infoBytes []byte) (int64, error) {
|
||||
text := strings.TrimSpace(string(infoBytes))
|
||||
expected, err := strconv.ParseInt(text, 10, 64)
|
||||
if err != nil || expected <= 0 {
|
||||
return 0, fmt.Errorf("gamedata: invalid size metadata %q", text)
|
||||
}
|
||||
return expected, nil
|
||||
}
|
||||
|
||||
func downloadSmall(ctx context.Context, client *http.Client, url string, limit int64) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: build metadata request: %w", err)
|
||||
}
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: download %s: %w", InfoName, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("gamedata: download %s: HTTP %s", InfoName, response.Status)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: read %s: %w", InfoName, err)
|
||||
}
|
||||
if int64(len(b)) > limit {
|
||||
return nil, fmt.Errorf("gamedata: %s exceeds %d bytes", InfoName, limit)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func downloadExact(ctx context.Context, client *http.Client, url string, target *os.File, expected int64) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: build archive request: %w", err)
|
||||
}
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: download %s: %w", ArchiveName, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("gamedata: download %s: HTTP %s", ArchiveName, response.Status)
|
||||
}
|
||||
written, err := io.Copy(target, io.LimitReader(response.Body, expected+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: write staged archive: %w", err)
|
||||
}
|
||||
if written != expected {
|
||||
return fmt.Errorf("gamedata: downloaded archive size %d, expected %d", written, expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func preserveExisting(path, stamp string) (string, bool, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("gamedata: inspect existing file %q: %w", path, err)
|
||||
}
|
||||
backup := path + ".broken." + stamp
|
||||
if err := os.Rename(path, backup); err != nil {
|
||||
return "", false, fmt.Errorf("gamedata: preserve invalid file %q: %w", path, err)
|
||||
}
|
||||
return backup, true, nil
|
||||
}
|
||||
Reference in New Issue
Block a user